diff --git a/README.md b/README.md index b96cdec78..aaa0e56eb 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,7 @@ Please open bug reports when you find things wrong, bug reports help me build th ViewModel-backed decode buffering for multipart messages. See `android/README.md` for build and usage details, and -`android/ENGINE_INTEGRATION_COMPLETE.md` for the end-to-end architecture notes. +`android/ARCHITECTURE.md` for how the app layer is put together. ## License diff --git a/adapters/android/jni/js8_engine_jni.cpp b/adapters/android/jni/js8_engine_jni.cpp index 810b5ad1e..94488ad3d 100644 --- a/adapters/android/jni/js8_engine_jni.cpp +++ b/adapters/android/jni/js8_engine_jni.cpp @@ -43,6 +43,8 @@ JNIEXPORT void JNICALL Java_com_js8call_core_JS8Engine_nativeStopTransmit(JNIEnv JNIEXPORT jboolean JNICALL Java_com_js8call_core_JS8Engine_nativeIsTransmitting(JNIEnv*, jobject, jlong); JNIEXPORT jboolean JNICALL Java_com_js8call_core_JS8Engine_nativeIsTransmittingAudio(JNIEnv*, jobject, jlong); JNIEXPORT jint JNICALL Java_com_js8call_core_JS8Engine_nativeTxMillisecondsUntilAudio(JNIEnv*, jobject, jlong); +JNIEXPORT jint JNICALL Java_com_js8call_core_JS8Engine_nativeTxFrameIndex(JNIEnv*, jobject, jlong); +JNIEXPORT jint JNICALL Java_com_js8call_core_JS8Engine_nativeTxFrameCount(JNIEnv*, jobject, jlong); JNIEXPORT void JNICALL Java_com_js8call_core_JS8Engine_nativeSetTxReady(JNIEnv*, jobject, jlong, jboolean); JNIEXPORT void JNICALL Java_com_js8call_core_JS8Engine_nativeSetTimeDriftMs(JNIEnv*, jobject, jlong, jlong); JNIEXPORT jlong JNICALL Java_com_js8call_core_JS8Engine_nativeGetTimeDriftMs(JNIEnv*, jobject, jlong); @@ -223,6 +225,9 @@ static bool is_callsign_like(std::string const& token) { return has_digit; } +// Only valid on a line's first frame. A continuation frame of a buffered +// command is plain payload text, and rewriting "KA0XYZ N0CALL QRV" into +// "KA0XYZ: N0CALL QRV" there corrupts the body and breaks its checksum. static std::string maybe_insert_callsign_prefix(std::string const& text) { std::size_t first_sep = std::string::npos; for (std::size_t i = 0; i < text.size(); ++i) { @@ -269,6 +274,10 @@ static std::string render_decoded_text(js8core::events::Decoded const& decoded) } bool is_data_flag = (decoded.type & 0b100) == 0b100; + // The transmitter sets this bit only on a line's first frame + // (varicode.cpp build_message_frames), so a continuation frame of a + // buffered command never carries it. + bool is_first_frame = (decoded.type & 0b1) == 0b1; // Try data payloads first (mirrors desktop unpack order). __android_log_print(ANDROID_LOG_DEBUG, "JS8FrameDebug", "Trying data unpacker: frame='%s', decoded.type=0x%02x", @@ -277,7 +286,9 @@ static std::string render_decoded_text(js8core::events::Decoded const& decoded) auto data = unpack_fast_data_message(frame); __android_log_print(ANDROID_LOG_DEBUG, "JS8FrameDebug", "unpack_fast_data returned: '%s'", data.c_str()); - if (!data.empty()) return maybe_insert_callsign_prefix(data); + if (!data.empty()) { + return is_first_frame ? maybe_insert_callsign_prefix(data) : data; + } // Fast-data frames should not be treated as heartbeat/compound/directed. __android_log_print(ANDROID_LOG_WARN, "JS8FrameDebug", "Fast data unpack failed, returning raw frame: '%s'", frame.c_str()); @@ -286,7 +297,9 @@ static std::string render_decoded_text(js8core::events::Decoded const& decoded) auto data = unpack_data_message(frame); __android_log_print(ANDROID_LOG_DEBUG, "JS8FrameDebug", "unpack_data returned: '%s'", data.c_str()); - if (!data.empty()) return maybe_insert_callsign_prefix(data); + if (!data.empty()) { + return is_first_frame ? maybe_insert_callsign_prefix(data) : data; + } } // Heartbeat (most common for status beacons) @@ -436,6 +449,17 @@ static void event_callback(JS8Engine_Native* native, js8core::events::Variant co env->CallVoidMethod(native->callback_handler, method, static_cast(decode_finished->decoded)); } + } else if (auto* timing = std::get_if(&event)) { + // Call onTimingSuggestion(int kind, int driftMs, int step, int steps) + jmethodID method = env->GetMethodID(handler_class, "onTimingSuggestion", "(IIIII)V"); + if (method) { + env->CallVoidMethod(native->callback_handler, method, + static_cast(timing->kind), + static_cast(timing->drift_ms), + static_cast(timing->step), + static_cast(timing->steps), + static_cast(timing->period_ms)); + } } env->DeleteLocalRef(handler_class); @@ -888,6 +912,16 @@ int js8_engine_tx_milliseconds_until_audio(JS8Engine_Native* engine) { return engine->engine->tx_milliseconds_until_audio(); } +int js8_engine_tx_frame_index(JS8Engine_Native* engine) { + if (!engine || !engine->engine) return 0; + return engine->engine->tx_frame_index(); +} + +int js8_engine_tx_frame_count(JS8Engine_Native* engine) { + if (!engine || !engine->engine) return 0; + return engine->engine->tx_frame_count(); +} + void js8_engine_set_tx_ready(JS8Engine_Native* engine, bool ready) { if (!engine || !engine->engine) return; engine->engine->set_tx_ready(ready); @@ -945,6 +979,10 @@ int js8_register_natives(JavaVM* vm, JNIEnv* env) { (void*)Java_com_js8call_core_JS8Engine_nativeIsTransmittingAudio}, {"nativeTxMillisecondsUntilAudio", "(J)I", (void*)Java_com_js8call_core_JS8Engine_nativeTxMillisecondsUntilAudio}, + {"nativeTxFrameIndex", "(J)I", + (void*)Java_com_js8call_core_JS8Engine_nativeTxFrameIndex}, + {"nativeTxFrameCount", "(J)I", + (void*)Java_com_js8call_core_JS8Engine_nativeTxFrameCount}, {"nativeSetTxReady", "(JZ)V", (void*)Java_com_js8call_core_JS8Engine_nativeSetTxReady}, {"nativeSetTimeDriftMs", "(JJ)V", diff --git a/adapters/android/jni/js8_engine_jni.h b/adapters/android/jni/js8_engine_jni.h index be5d8b8ce..9b1e0aa02 100644 --- a/adapters/android/jni/js8_engine_jni.h +++ b/adapters/android/jni/js8_engine_jni.h @@ -54,6 +54,8 @@ void js8_engine_stop_transmit(JS8Engine_Native* engine); int js8_engine_is_transmitting(JS8Engine_Native* engine); int js8_engine_is_transmitting_audio(JS8Engine_Native* engine); int js8_engine_tx_milliseconds_until_audio(JS8Engine_Native* engine); +int js8_engine_tx_frame_index(JS8Engine_Native* engine); +int js8_engine_tx_frame_count(JS8Engine_Native* engine); void js8_engine_set_tx_ready(JS8Engine_Native* engine, bool ready); // Status queries diff --git a/adapters/android/jni/js8_jni_methods.cpp b/adapters/android/jni/js8_jni_methods.cpp index c303bc4a2..9b363a98a 100644 --- a/adapters/android/jni/js8_jni_methods.cpp +++ b/adapters/android/jni/js8_jni_methods.cpp @@ -366,6 +366,24 @@ Java_com_js8call_core_JS8Engine_nativeTxMillisecondsUntilAudio( return static_cast(js8_engine_tx_milliseconds_until_audio(engine)); } +JNIEXPORT jint JNICALL +Java_com_js8call_core_JS8Engine_nativeTxFrameIndex( + JNIEnv* /* env */, + jobject /* thiz */, + jlong handle) { + JS8Engine_Native* engine = reinterpret_cast(handle); + return static_cast(js8_engine_tx_frame_index(engine)); +} + +JNIEXPORT jint JNICALL +Java_com_js8call_core_JS8Engine_nativeTxFrameCount( + JNIEnv* /* env */, + jobject /* thiz */, + jlong handle) { + JS8Engine_Native* engine = reinterpret_cast(handle); + return static_cast(js8_engine_tx_frame_count(engine)); +} + JNIEXPORT void JNICALL Java_com_js8call_core_JS8Engine_nativeSetTxReady( JNIEnv* /* env */, diff --git a/adapters/android/jni/kotlin/JS8Engine.kt b/adapters/android/jni/kotlin/JS8Engine.kt index f1540a030..d3a30a91e 100644 --- a/adapters/android/jni/kotlin/JS8Engine.kt +++ b/adapters/android/jni/kotlin/JS8Engine.kt @@ -232,6 +232,20 @@ class JS8Engine private constructor( return withNativeHandleOr(-1) { nativeTxMillisecondsUntilAudio(it) } } + /** + * 1-based index of the frame now being sent, or 0 when idle or tuning. + */ + fun txFrameIndex(): Int { + return withNativeHandleOr(0) { nativeTxFrameIndex(it) } + } + + /** + * Total frames in the current message, or 0 when idle or tuning. + */ + fun txFrameCount(): Int { + return withNativeHandleOr(0) { nativeTxFrameCount(it) } + } + /** Controls whether scheduled modulation may advance and reach the output. */ fun setTransmitReady(ready: Boolean) { withNativeHandle { nativeSetTxReady(it, ready) } @@ -337,6 +351,8 @@ class JS8Engine private constructor( private external fun nativeIsTransmitting(handle: Long): Boolean private external fun nativeIsTransmittingAudio(handle: Long): Boolean private external fun nativeTxMillisecondsUntilAudio(handle: Long): Int + private external fun nativeTxFrameIndex(handle: Long): Int + private external fun nativeTxFrameCount(handle: Long): Int private external fun nativeSetTxReady(handle: Long, ready: Boolean) private external fun nativeSetTimeDriftMs(handle: Long, driftMs: Long) private external fun nativeGetTimeDriftMs(handle: Long): Long @@ -396,5 +412,12 @@ class JS8Engine private constructor( * Called with TX audio PCM samples when enabled. */ fun onTxAudio(samples: ShortArray, sampleRateHz: Int) {} + + /** + * Raised when decoding has gone dead and the engine is trying shifted + * windows to find the clock offset. [kind] is 0 searching, 1 found, + * 2 gave up. [driftMs] is the total drift to apply on a find. + */ + fun onTimingSuggestion(kind: Int, driftMs: Int, step: Int, steps: Int, periodMs: Int) {} } } diff --git a/android/ARCHITECTURE.md b/android/ARCHITECTURE.md new file mode 100644 index 000000000..e6fbf9f1b --- /dev/null +++ b/android/ARCHITECTURE.md @@ -0,0 +1,398 @@ +# JS8Android app architecture + +How the Kotlin application layer is put together: the screens, the database, the +foreground service, and the seam between them and the native engine. + +This covers `android/app/` only. The C++ side is documented elsewhere: +`docs/backend-refactor-plan.md` for why `libjs8core` was extracted from the Qt +desktop, and `adapters/android/README.md` for the platform adapters that back it. +Build steps are in `android/README.md`. + +## The layers + +``` +core/ platform-agnostic DSP and protocol, no Qt + └── adapters/android/ Oboe audio, storage, logging, networking + └── jni/ js8_engine_jni.cpp, and JS8Engine.kt beside it + └── android/js8core-lib/ AAR wrapping the .so and the Kotlin API + └── android/app/ this document +``` + +`js8core-lib` has no Kotlin sources of its own. Its Gradle file pulls +`adapters/android/jni/kotlin` and `.../java` into its main source set, so the +JNI wrapper lives next to the C++ it wraps and ships as part of the AAR. + +## The three long-lived pieces + +**`JS8EngineService`** (`service/`, ~4700 lines) is a foreground service and the +only owner of the native engine. It captures audio, feeds the decoder, receives +decode callbacks, interprets the JS8 protocol, writes to the database, and drives +transmission. It runs whether or not any screen is showing. + +**`MainActivity`** (~540 lines) hosts the navigation graph and acts as a broadcast +hub. It listens for what the service emits, forwards it into ViewModels, and +pumps the transmit queue. + +**Fragments and ViewModels** (`ui/`) render. ViewModels are scoped to the +activity, not the fragment, so a thread and the list behind it read the same +instance. + +The service and the UI never call each other directly. They talk over +`LocalBroadcastManager` with about twenty-six actions declared as constants on +`JS8EngineService`. The service→UI direction carries decodes, spectrum frames, +engine state, TX state and progress, rig status, and time drift. The UI→service +direction carries start, stop, transmit, set frequency, set TX offset, and audio +device switches. + +That seam is what lets the service keep decoding with no UI attached. It also +costs something, described under **Transmission** below. + +## Data + +Room, currently at **version 6**, in `data/`. Five tables: + +| Table | What it holds | +|---|---| +| `messages` | Two-party and group threads, keyed by `conversationId` | +| `contacts` | Every station heard, plus the operator's own name, star, and notes | +| `mailbox_messages` | Store-and-forward mail held for **other** stations | +| `mailbox_group_delivery` | Which callsigns have collected which group message | +| `conversation_settings` | Per-thread settings; currently the relay path | + +Held mail deliberately does not live in `messages`. A held message has an +originator and a destination and neither one is us, so putting it there would +manufacture phantom rows in the conversation query. + +**Migrations are hand-written and destructive fallback is off.** Schemas are +exported to `android/app/schemas/`, and `MigrationTest` (instrumented) runs the +3→4, 4→5 and 5→6 steps against a seeded database. A version gap now crashes on +upgrade rather than silently wiping, which matters once the database holds traffic +we promised a third party we would forward. Migrations exist from version 2 +onward; a version 1 database predates the export and would fail. + +Repositories (`MessageRepository`, `ContactRepository`, `MailboxRepository`) wrap +the DAOs and move work to `Dispatchers.IO`. The service holds its own repository +instances; ViewModels hold theirs. + +One join is done in Kotlin rather than SQL: threads come from `messages` and +names from `contacts`, and `MessagesFragment` hands the adapter a callsign-to-name +map instead of joining the two. `MessageDao.getConversations()` matches +`timestamp = MAX(timestamp)` per conversation and already produces a duplicate row +when two messages share a millisecond, so it was left alone. + +## Receiving + +``` +AudioRecord ──▶ JS8AudioHelper ──▶ engine.submitAudio() + │ (native decode cycle) + ▼ + CallbackHandler.onDecoded(utc, snr, dt, freq, + text, type, quality, + mode, driftMs) + │ + ▼ + JS8EngineService + ┌───────────────────┼───────────────────┐ + ▼ ▼ ▼ + broadcastDecode handleRelayFrame maybeHandleIncomingMessage + (waterfall, maybeHandleAutoReply + decode list) │ + ▼ + ACTION_MESSAGE_RECEIVED ──▶ MainActivity + │ │ + ▼ ▼ + notification Room insert +``` + +Every decode runs through all three handlers. `type` carries the frame-type bits +the protocol uses: `0b1` first frame, `0b10` last frame, `0b100` data frame. Those +bits are load-bearing — a buffered command's payload arrives across continuation +frames, and telling a first frame from a continuation is what keeps the JNI's +callsign-prefix heuristic from rewriting message bodies. + +**Multi-frame reassembly** happens in the service, in two separate buffer maps +keyed by audio offset: `msgBuffers` for `MSG` and `MSG TO:`, `relayBuffers` for +relay (`>`) traffic. Buffers expire on a timeout scaled to the submode's frame +period, because a Slow-mode transmission spaces frames thirty seconds apart and a +flat timeout truncated them. + +**Protocol handling** lives in the service too. Directed commands are parsed by +`util/Js8Commands`, which matches longest-name-first so `QUERY MSGS` does not +arrive as `QUERY` and `MSG TO:` does not arrive as `MSG`. The service implements +the store-and-forward mailbox (`MSG TO:` deposits, `QUERY MSGS`, `QUERY MSG {id}`), +relay forwarding and delivery, auto-replies to the query commands, heartbeats, and +ACK handling. + +## Clock alignment + +JS8 is a timed mode. The decoder searches a window of about **±2.48 seconds** +around where it expects a frame to start (`JZ = 62` steps of `NSPS / 4` samples, +`core/src/decoder/legacy_decoder.cpp`). A clock further off than that decodes +nothing, while the waterfall keeps looking normal, because an FFT has no timing +dependence. That combination — signals visible, decodes zero — is the signature +of a clock problem and of nothing else. + +The engine never sets the system clock. It holds an offset instead: + +``` +drifted_now() = system_clock::now() + time_drift_ms_ +``` + +That offset feeds `align_ring_to_clock()`, which snaps the RX ring to the UTC +minute, and it feeds the transmit start. A drift change sets +`drift_realign_pending_`, and the next capture buffer re-snaps the ring on the +audio thread that owns it. Nothing else moves. + +**Three things can set the offset.** + +1. *Sync from a decode.* `compute_drift_estimate()` runs inside the decode + callback and returns the total drift that would centre that signal. The + service applies it when auto-sync is on or a one-shot sync is armed. This is + the desktop's algorithm and it is accurate, but it needs a decode, so it + cannot recover a clock that is too wrong to decode anything. +2. *Manual entry.* Monitor overflow, "Adjust time drift", or a tap on the drift + readout in the status strip. Capped at ±30 s because the ring aligns to the + UTC minute and a larger value wraps onto a smaller one. +3. *The blind search.* Described below. It exists because 1 cannot start itself + and 2 asks the user to know a number they have no way to measure. + +### The blind search + +``` +decode cycle (primary submode) ──▶ note_decode_cycle(decoded) + │ │ + decoded > 0 8 quiet cycles + │ │ + ▼ ▼ + counter reset search armed + │ + one shifted window per cycle, 3 of them + │ + ┌─────────────────────┴──────────────────┐ + ▼ ▼ + a trial decodes all three empty + │ │ + ▼ ▼ + TimingSuggestion::Found TimingSuggestion::GaveUp + (drift_ms from the same quiet_cycles = -40, so the + compute_drift_estimate) next attempt is far off +``` + +**Arming** is a decode drought, not a signal test. `note_decode_cycle()` counts +only tasks that carry the primary submode, which is the longest-period submode +enabled — Normal when it is on. Any decode resets the counter to zero and +disarms. Eight consecutive empty cycles arm the search, so two minutes of +silence on Normal. + +The sharper trigger would be "sync candidates found but zero decodes". The +decoder can report those, but only when `syncStats` is set, and +`populate_decode_metadata()` leaves it false because the flag emits an event per +candidate. A drought is looser and arms on a genuinely dead band too. That costs +nothing visible: the user only ever sees something when a shifted window +**actually decodes a message**, so a false arm burns one extra decode per cycle +for three cycles and then gives up in silence. + +**Trials** need no decoder change. `schedule_decodes()` already snapshots the +ring plus a `kposX`/`kszX` window and enqueues it, so a trial is one more +snapshot with a shifted `kpos` and `timing_trial = true`. Coverage per attempt +is the decoder's own ±2.48 s, so three trials spaced a quarter period apart, +plus the ordinary window, cover a whole period with overlap. + +Shifts are always **backwards**. A forward shift would read past the ring's +write pointer into the previous minute's audio. A step earlier is the same phase +modulo the period and it is real, already-captured data. + +A trial that decodes calls `report_timing_found()` and its decode is **not** +reported as traffic. Reporting it would duplicate the message once the offset is +accepted and the same audio decodes again in the ordinary window. + +`finish_timing_trial()` is what gives up, not the code that queues the trials. +Decoding runs on its own thread, so the sweep is only spent when the last trial +returns. Giving up when the last one was merely queued announced failure a +fraction of a second before the answer arrived. + +### Surfacing it + +A correction is a decision, so the app proposes and the user accepts. Nothing is +applied automatically. That matters because a single mistimed station can drag +sync-from-decode onto its own clock, which is a real failure and not a rare one: +of the eight stations in `media/tests/A_2_9.wav`, one sits at DT +1.58 s while +the rest are inside ±0.35 s. + +| Where | What | +|---|---| +| Monitor | An elevated card with `Dismiss` and `Fix it` | +| Anywhere else | A snackbar whose action takes the user to Monitor | +| Monitor nav item | A badge for as long as a suggestion is pending | +| Status strip, while searching | The offset stays, plus a countdown | + +`MainActivity.renderTimingSurface()` decides between the card and the snackbar, +and it runs on a new suggestion **and on every navigation**, because moving off +Monitor is what makes the snackbar right and moving back is what retires it. +Neither is a change to the suggestion itself, so a LiveData observer alone +misses both. + +The countdown is derived, not guessed. `TimingSuggestion` carries `period_ms` +for the submode being hunted, and the remaining time is +`(steps - step + 1) x period`, since each outstanding trial takes one frame. The +deadline is re-armed on every trial event, so a slipped cycle corrects itself +instead of accumulating. Turbo counts down from 18 s and Slow from 90 s with no +extra code. + +`Dismiss` clears the shared suggestion rather than hiding the card. The card is +not the only surface, so a local hide would leave the badge and the snackbar up. + +### Numbers and where they live + +| Constant | Value | File | +|---|---|---| +| `kTimingQuietCycles` | 8 | `core/src/engine/engine.cpp` | +| `kTimingTrialSteps` | 3 | same | +| `kTimingRetryCycles` | 40 | same | +| Decoder DT search | ±2.48 s | `legacy_decoder.cpp` (`JZ`, `NSSY`) | +| Manual drift cap | ±30 s | `MonitorFragment.DRIFT_LIMIT_MS` | + +### Driving the UI without waiting + +Debug builds accept a timing suggestion straight from an intent, so the surfaces +can be checked without a two-minute drought and a lucky trial: + +```sh +./tools/show-timing-banner.sh found # card, or snackbar when off Monitor +./tools/show-timing-banner.sh searching # strip countdown +./tools/show-timing-banner.sh hide +``` + +It is read by `MainActivity.applyDebugTimingSuggestion()` and gated on +`BuildConfig.DEBUG`. The service has a matching `ACTION_DEBUG_INJECT_TIMING`, +which is unreachable from `am` because the service is not exported. + +## Transmitting + +There are **two** transmit paths, and the difference matters. + +**Direct.** `sendAutoReply`, `sendRelayMessage`, and `sendHeartbeat` call +`engine.transmitMessage` from inside the service. These work with no UI attached. + +**Queued.** Everything else goes: + +``` +fragment ──▶ TransmitViewModel.queueMessage() + │ + ▼ + MainActivity.processNextTxIfIdle() + │ ACTION_TRANSMIT_MESSAGE + ▼ + JS8EngineService ──▶ engine.transmitMessage() + │ ACTION_TX_STATE / ACTION_TX_PROGRESS + ▼ + MainActivity pumps the next one +``` + +The service can also *ask* for a transmission with `broadcastQueueTx`, which sends +`ACTION_QUEUE_TX` to the activity and comes back around through the same pump. +Sixteen call sites use it, including every mailbox reply, because delivery is +marked when the transmission finishes and the completion hook lives on the queue. + +**Known limitation:** `MainActivity` registers its receivers in `onStart` and +unregisters them in `onStop`. While the app is backgrounded the pump is not +running, so anything routed through `broadcastQueueTx` waits until the app is +foregrounded again. Decoding continues; queued replies do not go out. The queue is +also in memory only, so process death loses it. Both are worth fixing by moving +the pump into the service and the queue into the database. + +## Where Kotlin mirrors native tables + +Two lookup tables are duplicated from `core/src/protocol/varicode.cpp` into +Kotlin, because crossing JNI for them on the decode hot path was not worth it: + +- `util/Js8Commands` mirrors `kDirectedCmds` +- `util/Js8Groups` mirrors `kBaseCalls` + +Both have **drift guards**: unit tests that read `varicode.cpp` off disk, extract +the native table, and fail if the Kotlin copy has diverged. A change to the native +vocabulary breaks the build rather than silently producing frames the other end +cannot parse. + +## Screens + +Four tabs, with six further destinations reached from them. Wide screens in +landscape get a `NavigationRailView` instead of the bottom bar; the code addresses +either through `NavigationBarView`, the shared superclass. + +| Destination | Purpose | +|---|---| +| **Monitor** | Waterfall, status strip, and the decode list, which was merged in from its own tab | +| **Messages** | Thread list, plus a pinned All activity row | +| ├ Conversation | One thread; compose bar, relay-path strip, mailbox actions | +| ├ Everything | All band activity as a chat thread | +| ├ Held messages | Mail this station is holding for others | +| ├ Other groups | Group threads the operator has not joined | +| ├ Relay path | Ordered hops for one thread | +| **Contacts** | Every station heard, searchable | +| └ Contact detail | Identity, editable name and notes, relay path, favourite, delete | +| **Settings** | Callsign, grid, audio, rig control, autoreply, mailbox, groups | + +The waterfall is a custom `View` with its own renderer (`WaterfallView`, +`WaterfallRenderer`) fed by `ACTION_SPECTRUM`. + +## Audio and rig control + +Capture is `AudioRecord` through `JS8AudioHelper` at 12 kHz. Transmit audio is +generated natively and played through Oboe in the adapter layer. + +Rig control has four backends, all driven from the service: `HamlibRigControl` +(native, USB), `RigCtlClient` (network `rigctld`), `TruSdxDirectSerial`, and +`BluetoothSerialBridge`. `UsbPermissionHelper` handles the Android USB permission +dance. + +`PskReporterClient` spots decodes to PSKReporter when enabled. + +## Tests + +**Unit** (`app/src/test/`, JVM): the protocol vocabulary and its drift guards, +multi-frame assembly, relay path composition, callsign validation, TX message +classification, contact search, display names, avatar colours, PSKReporter +encoding. + +**Instrumented** (`app/src/androidTest/`): `MigrationTest` covers every schema +step against a seeded database. + +**Engine-level** (`android/js8core-lib/src/androidTest/`): lifecycle, audio +submission, and TX timing against the real native engine. + +Two of those run signal through the engine rather than around it. +`JS8EngineReferenceDecodeTest` feeds the desktop project's own recordings from +`media/tests/` straight into the decoder, so a failure is the decoder and not the +microphone or the radio. It is a smoke test rather than desktop parity: the +engine decodes a 13.6 second window at a fixed depth where the desktop CLI reads +the whole file, so the counts run a little under the names the files carry. It +also prints each decode's DT, which is the ground truth for judging whether an +alignment change helped. + +`JS8EngineLoopbackTest` transmits, captures the waveform off the TX tap and +decodes it, with no speaker or microphone in the path, so a failure there is the +transmitted audio itself. Note the tap sits **after** the output resampler, so it +runs at whatever rate the audio device negotiated — 11520 Hz on the emulator, not +the engine's 12000 — and the test resamples before decoding. + +There is also a **debug-only decode injection path**. A broadcast to +`DebugDecodeReceiver` (debug source set only) feeds synthetic decode text through +the same handler chain a real decode takes, which makes multi-frame commands, +malformed frames, and bad checksums testable on one emulator with no audio. The +receiver does not exist in release builds. + +## Known weak points + +Recorded here so they are not rediscovered: + +- The TX pump depends on `MainActivity` being started, and the queue is in memory. + See **Transmitting**. +- `MessageDao.getConversations()` emits a duplicate row when two messages in one + conversation share a millisecond. +- `TransmitViewModel` re-sorts by priority on every add while popping index 0 on + completion, so a high-priority insert during an airborne send can attribute the + result to the wrong `dbId`. +- `JS8EngineService` is ~4700 lines and carries audio, rig control, protocol, + mailbox, and notifications together. The protocol handling is the obvious first + thing to lift out. diff --git a/android/EMULATOR_SETUP.md b/android/EMULATOR_SETUP.md new file mode 100644 index 000000000..0fbe378b2 --- /dev/null +++ b/android/EMULATOR_SETUP.md @@ -0,0 +1,113 @@ +# Android emulator setup on this Mac + +Reference for the emulator install done on 2026-08-20 (Apple M5, macOS, Homebrew SDK). The emulator gives a fast loop for UI work. Final checks still belong on the Fire HD 10, because the tablet runs 32-bit Fire OS at a different density. + +## What is installed + +- SDK root: `/opt/homebrew/share/android-commandlinetools` +- Emulator: build 16079175 (`emulator-darwin_aarch64`), in `/emulator` +- System image: API 34, `google_apis`, `arm64-v8a`, revision 14, in `/system-images/android-34/google_apis/arm64-v8a` +- AVD: `firehd10`, config in `~/.android/avd/firehd10.avd/` + +The AVD uses the "10.1in WXGA (Tablet)" profile with these overrides in `config.ini`: + +``` +hw.lcd.width = 1920 +hw.lcd.height = 1200 +hw.lcd.density = 240 +hw.keyboard = yes +hw.ramSize = 2048 +``` + +This approximates the Fire HD 10 screen (10.1 inch, 1920x1200). + +## Why the install was manual + +`sdkmanager` corrupts large zip downloads on this machine. It fails with "Error reading Zip content from a SeekableByteChannel". The NDK install hit the same bug earlier. The fix is the same: download the zips from `dl.google.com` directly and unzip them into the SDK. + +1. Find the current file names in the repository manifests: + - Emulator: `https://dl.google.com/android/repository/repository2-3.xml` + - System images: `https://dl.google.com/android/repository/sys-img/google_apis/sys-img2-3.xml` +2. Download and unzip: + + ```sh + SDK=/opt/homebrew/share/android-commandlinetools + curl -sSfO https://dl.google.com/android/repository/emulator-darwin_aarch64-16079175.zip + curl -sSfO https://dl.google.com/android/repository/sys-img/google_apis/arm64-v8a-34_r14.zip + unzip -q emulator-darwin_aarch64-16079175.zip -d $SDK + mkdir -p $SDK/system-images/android-34/google_apis + unzip -q arm64-v8a-34_r14.zip -d $SDK/system-images/android-34/google_apis + ``` + +3. `avdmanager` refuses a manually unzipped emulator with the error `"emulator" package must be installed!`. It wants a `package.xml` next to the binary. This file was written by hand at `/emulator/package.xml`: + + ```xml + + + + + 3725 + Android Emulator + + + ``` + + Match the revision to `Pkg.Revision` in `/emulator/source.properties`. The system image zip already contains its own `package.xml`. + +## AVD creation (already done, repeat only if deleted) + +```sh +export JAVA_HOME=/opt/homebrew/opt/openjdk@17 +SDK=/opt/homebrew/share/android-commandlinetools +echo no | $SDK/cmdline-tools/latest/bin/avdmanager create avd -n firehd10 \ + -k "system-images;android-34;google_apis;arm64-v8a" -d "10.1in WXGA (Tablet)" +``` + +Then apply the `config.ini` overrides listed above. Warnings about `devices.xml` are noise. + +There is also a phone AVD named `phone`, on the Pixel 7 profile (1080x2400 at 420 dpi). It shares the API 34 system image: + +```sh +$SDK/cmdline-tools/latest/bin/avdmanager create avd -n phone \ + -k "system-images;android-34;google_apis;arm64-v8a" -d pixel_7 +``` + +Set `hw.keyboard=yes` in `~/.android/avd/phone.avd/config.ini` so `adb shell input text` works. + +Both AVDs run at the same time on different ports: + +```sh +$SDK/emulator/emulator -avd firehd10 -port 5554 & +$SDK/emulator/emulator -avd phone -port 5556 & +``` + +Target them as `adb -s emulator-5554` (tablet) and `adb -s emulator-5556` (phone). + +## Daily use + +Start the emulator: + +```sh +ANDROID_SDK_ROOT=/opt/homebrew/share/android-commandlinetools \ + /opt/homebrew/share/android-commandlinetools/emulator/emulator -avd firehd10 +``` + +Build and install the app (`-e` targets the emulator when the tablet is also connected): + +```sh +cd android +JAVA_HOME=/opt/homebrew/opt/openjdk@17 ./gradlew assembleDebug +adb -e install -r app/build/outputs/apk/debug/app-debug.apk +``` + +Take a screenshot: + +```sh +adb -e exec-out screencap -p > screen.png +``` + +## Limits + +- No radio hardware. But TX audio plays through the host speakers, and the host microphone feeds the waterfall, so a speaker-to-microphone loop can decode the app's own transmissions. If the microphone reads near silence (RMS ~2 in the logs), re-enable host audio with `adb emu avd hostmicon`. +- The image is 64-bit Android 14. The tablet is 32-bit Fire OS. Test release candidates on the tablet. +- The app and its native engine start without problems on the emulator. diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 20c01249b..3a76c7a7f 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -84,6 +84,17 @@ android { viewBinding = true buildConfig = true } + + // The migration test builds old-version databases from these schema files. + sourceSets { + getByName("androidTest") { + assets.srcDir("$projectDir/schemas") + } + } +} + +ksp { + arg("room.schemaLocation", "$projectDir/schemas") } dependencies { @@ -125,4 +136,5 @@ dependencies { testImplementation("junit:junit:4.13.2") androidTestImplementation("androidx.test.ext:junit:1.1.5") androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1") + androidTestImplementation("androidx.room:room-testing:2.6.1") } diff --git a/android/app/schemas/com.js8call.example.data.MessageDatabase/3.json b/android/app/schemas/com.js8call.example.data.MessageDatabase/3.json new file mode 100644 index 000000000..bea8abe01 --- /dev/null +++ b/android/app/schemas/com.js8call.example.data.MessageDatabase/3.json @@ -0,0 +1,200 @@ +{ + "formatVersion": 1, + "database": { + "version": 3, + "identityHash": "8cf935dc1d9ce0bb48c5ed45d52923b2", + "entities": [ + { + "tableName": "messages", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `conversationId` TEXT NOT NULL, `direction` INTEGER NOT NULL, `senderCallsign` TEXT, `text` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, `snr` INTEGER, `frequency` REAL, `status` INTEGER NOT NULL, `isRead` INTEGER NOT NULL, `relayPath` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "conversationId", + "columnName": "conversationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "direction", + "columnName": "direction", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "senderCallsign", + "columnName": "senderCallsign", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "text", + "columnName": "text", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "snr", + "columnName": "snr", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "frequency", + "columnName": "frequency", + "affinity": "REAL", + "notNull": false + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isRead", + "columnName": "isRead", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "relayPath", + "columnName": "relayPath", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_messages_conversationId", + "unique": false, + "columnNames": [ + "conversationId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_messages_conversationId` ON `${TABLE_NAME}` (`conversationId`)" + }, + { + "name": "index_messages_timestamp", + "unique": false, + "columnNames": [ + "timestamp" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_messages_timestamp` ON `${TABLE_NAME}` (`timestamp`)" + }, + { + "name": "index_messages_isRead", + "unique": false, + "columnNames": [ + "isRead" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_messages_isRead` ON `${TABLE_NAME}` (`isRead`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "contacts", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`callsign` TEXT NOT NULL, `lastHeard` INTEGER NOT NULL, `snr` INTEGER, `offset` REAL, `grid` TEXT, `info` TEXT, `heardUs` INTEGER NOT NULL, `starred` INTEGER NOT NULL, `comment` TEXT, PRIMARY KEY(`callsign`))", + "fields": [ + { + "fieldPath": "callsign", + "columnName": "callsign", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastHeard", + "columnName": "lastHeard", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "snr", + "columnName": "snr", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "offset", + "columnName": "offset", + "affinity": "REAL", + "notNull": false + }, + { + "fieldPath": "grid", + "columnName": "grid", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "info", + "columnName": "info", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "heardUs", + "columnName": "heardUs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "starred", + "columnName": "starred", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "comment", + "columnName": "comment", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "callsign" + ] + }, + "indices": [ + { + "name": "index_contacts_lastHeard", + "unique": false, + "columnNames": [ + "lastHeard" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_contacts_lastHeard` ON `${TABLE_NAME}` (`lastHeard`)" + } + ], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '8cf935dc1d9ce0bb48c5ed45d52923b2')" + ] + } +} \ No newline at end of file diff --git a/android/app/schemas/com.js8call.example.data.MessageDatabase/4.json b/android/app/schemas/com.js8call.example.data.MessageDatabase/4.json new file mode 100644 index 000000000..ee3481ae1 --- /dev/null +++ b/android/app/schemas/com.js8call.example.data.MessageDatabase/4.json @@ -0,0 +1,359 @@ +{ + "formatVersion": 1, + "database": { + "version": 4, + "identityHash": "da46a9fbf580004f5332e383d6429bcf", + "entities": [ + { + "tableName": "messages", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `conversationId` TEXT NOT NULL, `direction` INTEGER NOT NULL, `senderCallsign` TEXT, `text` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, `snr` INTEGER, `frequency` REAL, `status` INTEGER NOT NULL, `isRead` INTEGER NOT NULL, `relayPath` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "conversationId", + "columnName": "conversationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "direction", + "columnName": "direction", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "senderCallsign", + "columnName": "senderCallsign", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "text", + "columnName": "text", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "snr", + "columnName": "snr", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "frequency", + "columnName": "frequency", + "affinity": "REAL", + "notNull": false + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isRead", + "columnName": "isRead", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "relayPath", + "columnName": "relayPath", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_messages_conversationId", + "unique": false, + "columnNames": [ + "conversationId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_messages_conversationId` ON `${TABLE_NAME}` (`conversationId`)" + }, + { + "name": "index_messages_timestamp", + "unique": false, + "columnNames": [ + "timestamp" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_messages_timestamp` ON `${TABLE_NAME}` (`timestamp`)" + }, + { + "name": "index_messages_isRead", + "unique": false, + "columnNames": [ + "isRead" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_messages_isRead` ON `${TABLE_NAME}` (`isRead`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "contacts", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`callsign` TEXT NOT NULL, `lastHeard` INTEGER NOT NULL, `snr` INTEGER, `offset` REAL, `grid` TEXT, `info` TEXT, `heardUs` INTEGER NOT NULL, `starred` INTEGER NOT NULL, `comment` TEXT, PRIMARY KEY(`callsign`))", + "fields": [ + { + "fieldPath": "callsign", + "columnName": "callsign", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastHeard", + "columnName": "lastHeard", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "snr", + "columnName": "snr", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "offset", + "columnName": "offset", + "affinity": "REAL", + "notNull": false + }, + { + "fieldPath": "grid", + "columnName": "grid", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "info", + "columnName": "info", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "heardUs", + "columnName": "heardUs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "starred", + "columnName": "starred", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "comment", + "columnName": "comment", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "callsign" + ] + }, + "indices": [ + { + "name": "index_contacts_lastHeard", + "unique": false, + "columnNames": [ + "lastHeard" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_contacts_lastHeard` ON `${TABLE_NAME}` (`lastHeard`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "mailbox_messages", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `originator` TEXT NOT NULL, `destination` TEXT NOT NULL, `text` TEXT NOT NULL, `receivedAt` INTEGER NOT NULL, `originatedAt` INTEGER, `relayPath` TEXT, `snr` INTEGER, `offsetHz` REAL, `state` INTEGER NOT NULL, `deliveredAt` INTEGER, `origin` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "originator", + "columnName": "originator", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "destination", + "columnName": "destination", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "text", + "columnName": "text", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "receivedAt", + "columnName": "receivedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "originatedAt", + "columnName": "originatedAt", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "relayPath", + "columnName": "relayPath", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "snr", + "columnName": "snr", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "offsetHz", + "columnName": "offsetHz", + "affinity": "REAL", + "notNull": false + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "deliveredAt", + "columnName": "deliveredAt", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "origin", + "columnName": "origin", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_mailbox_messages_destination", + "unique": false, + "columnNames": [ + "destination" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_mailbox_messages_destination` ON `${TABLE_NAME}` (`destination`)" + }, + { + "name": "index_mailbox_messages_state", + "unique": false, + "columnNames": [ + "state" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_mailbox_messages_state` ON `${TABLE_NAME}` (`state`)" + }, + { + "name": "index_mailbox_messages_receivedAt", + "unique": false, + "columnNames": [ + "receivedAt" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_mailbox_messages_receivedAt` ON `${TABLE_NAME}` (`receivedAt`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "mailbox_group_delivery", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`msgId` INTEGER NOT NULL, `callsign` TEXT NOT NULL, `deliveredAt` INTEGER NOT NULL, PRIMARY KEY(`msgId`, `callsign`), FOREIGN KEY(`msgId`) REFERENCES `mailbox_messages`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "msgId", + "columnName": "msgId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "callsign", + "columnName": "callsign", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "deliveredAt", + "columnName": "deliveredAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "msgId", + "callsign" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "mailbox_messages", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "msgId" + ], + "referencedColumns": [ + "id" + ] + } + ] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'da46a9fbf580004f5332e383d6429bcf')" + ] + } +} \ No newline at end of file diff --git a/android/app/schemas/com.js8call.example.data.MessageDatabase/5.json b/android/app/schemas/com.js8call.example.data.MessageDatabase/5.json new file mode 100644 index 000000000..8bcb3f33d --- /dev/null +++ b/android/app/schemas/com.js8call.example.data.MessageDatabase/5.json @@ -0,0 +1,385 @@ +{ + "formatVersion": 1, + "database": { + "version": 5, + "identityHash": "09932cafed5a6f83ef5498740185798e", + "entities": [ + { + "tableName": "messages", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `conversationId` TEXT NOT NULL, `direction` INTEGER NOT NULL, `senderCallsign` TEXT, `text` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, `snr` INTEGER, `frequency` REAL, `status` INTEGER NOT NULL, `isRead` INTEGER NOT NULL, `relayPath` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "conversationId", + "columnName": "conversationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "direction", + "columnName": "direction", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "senderCallsign", + "columnName": "senderCallsign", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "text", + "columnName": "text", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "snr", + "columnName": "snr", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "frequency", + "columnName": "frequency", + "affinity": "REAL", + "notNull": false + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isRead", + "columnName": "isRead", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "relayPath", + "columnName": "relayPath", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_messages_conversationId", + "unique": false, + "columnNames": [ + "conversationId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_messages_conversationId` ON `${TABLE_NAME}` (`conversationId`)" + }, + { + "name": "index_messages_timestamp", + "unique": false, + "columnNames": [ + "timestamp" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_messages_timestamp` ON `${TABLE_NAME}` (`timestamp`)" + }, + { + "name": "index_messages_isRead", + "unique": false, + "columnNames": [ + "isRead" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_messages_isRead` ON `${TABLE_NAME}` (`isRead`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "contacts", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`callsign` TEXT NOT NULL, `lastHeard` INTEGER NOT NULL, `snr` INTEGER, `offset` REAL, `grid` TEXT, `info` TEXT, `heardUs` INTEGER NOT NULL, `starred` INTEGER NOT NULL, `comment` TEXT, PRIMARY KEY(`callsign`))", + "fields": [ + { + "fieldPath": "callsign", + "columnName": "callsign", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastHeard", + "columnName": "lastHeard", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "snr", + "columnName": "snr", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "offset", + "columnName": "offset", + "affinity": "REAL", + "notNull": false + }, + { + "fieldPath": "grid", + "columnName": "grid", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "info", + "columnName": "info", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "heardUs", + "columnName": "heardUs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "starred", + "columnName": "starred", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "comment", + "columnName": "comment", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "callsign" + ] + }, + "indices": [ + { + "name": "index_contacts_lastHeard", + "unique": false, + "columnNames": [ + "lastHeard" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_contacts_lastHeard` ON `${TABLE_NAME}` (`lastHeard`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "mailbox_messages", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `originator` TEXT NOT NULL, `destination` TEXT NOT NULL, `text` TEXT NOT NULL, `receivedAt` INTEGER NOT NULL, `originatedAt` INTEGER, `relayPath` TEXT, `snr` INTEGER, `offsetHz` REAL, `state` INTEGER NOT NULL, `deliveredAt` INTEGER, `origin` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "originator", + "columnName": "originator", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "destination", + "columnName": "destination", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "text", + "columnName": "text", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "receivedAt", + "columnName": "receivedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "originatedAt", + "columnName": "originatedAt", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "relayPath", + "columnName": "relayPath", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "snr", + "columnName": "snr", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "offsetHz", + "columnName": "offsetHz", + "affinity": "REAL", + "notNull": false + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "deliveredAt", + "columnName": "deliveredAt", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "origin", + "columnName": "origin", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_mailbox_messages_destination", + "unique": false, + "columnNames": [ + "destination" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_mailbox_messages_destination` ON `${TABLE_NAME}` (`destination`)" + }, + { + "name": "index_mailbox_messages_state", + "unique": false, + "columnNames": [ + "state" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_mailbox_messages_state` ON `${TABLE_NAME}` (`state`)" + }, + { + "name": "index_mailbox_messages_receivedAt", + "unique": false, + "columnNames": [ + "receivedAt" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_mailbox_messages_receivedAt` ON `${TABLE_NAME}` (`receivedAt`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "mailbox_group_delivery", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`msgId` INTEGER NOT NULL, `callsign` TEXT NOT NULL, `deliveredAt` INTEGER NOT NULL, PRIMARY KEY(`msgId`, `callsign`), FOREIGN KEY(`msgId`) REFERENCES `mailbox_messages`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "msgId", + "columnName": "msgId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "callsign", + "columnName": "callsign", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "deliveredAt", + "columnName": "deliveredAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "msgId", + "callsign" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "mailbox_messages", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "msgId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "conversation_settings", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`conversationId` TEXT NOT NULL, `relayPath` TEXT, PRIMARY KEY(`conversationId`))", + "fields": [ + { + "fieldPath": "conversationId", + "columnName": "conversationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relayPath", + "columnName": "relayPath", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "conversationId" + ] + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '09932cafed5a6f83ef5498740185798e')" + ] + } +} \ No newline at end of file diff --git a/android/app/schemas/com.js8call.example.data.MessageDatabase/6.json b/android/app/schemas/com.js8call.example.data.MessageDatabase/6.json new file mode 100644 index 000000000..98b17bd76 --- /dev/null +++ b/android/app/schemas/com.js8call.example.data.MessageDatabase/6.json @@ -0,0 +1,391 @@ +{ + "formatVersion": 1, + "database": { + "version": 6, + "identityHash": "d4b2d1fee965b0fa82c7df79aa2379e5", + "entities": [ + { + "tableName": "messages", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `conversationId` TEXT NOT NULL, `direction` INTEGER NOT NULL, `senderCallsign` TEXT, `text` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, `snr` INTEGER, `frequency` REAL, `status` INTEGER NOT NULL, `isRead` INTEGER NOT NULL, `relayPath` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "conversationId", + "columnName": "conversationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "direction", + "columnName": "direction", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "senderCallsign", + "columnName": "senderCallsign", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "text", + "columnName": "text", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "snr", + "columnName": "snr", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "frequency", + "columnName": "frequency", + "affinity": "REAL", + "notNull": false + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isRead", + "columnName": "isRead", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "relayPath", + "columnName": "relayPath", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_messages_conversationId", + "unique": false, + "columnNames": [ + "conversationId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_messages_conversationId` ON `${TABLE_NAME}` (`conversationId`)" + }, + { + "name": "index_messages_timestamp", + "unique": false, + "columnNames": [ + "timestamp" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_messages_timestamp` ON `${TABLE_NAME}` (`timestamp`)" + }, + { + "name": "index_messages_isRead", + "unique": false, + "columnNames": [ + "isRead" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_messages_isRead` ON `${TABLE_NAME}` (`isRead`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "contacts", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`callsign` TEXT NOT NULL, `name` TEXT, `lastHeard` INTEGER NOT NULL, `snr` INTEGER, `offset` REAL, `grid` TEXT, `info` TEXT, `heardUs` INTEGER NOT NULL, `starred` INTEGER NOT NULL, `comment` TEXT, PRIMARY KEY(`callsign`))", + "fields": [ + { + "fieldPath": "callsign", + "columnName": "callsign", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastHeard", + "columnName": "lastHeard", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "snr", + "columnName": "snr", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "offset", + "columnName": "offset", + "affinity": "REAL", + "notNull": false + }, + { + "fieldPath": "grid", + "columnName": "grid", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "info", + "columnName": "info", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "heardUs", + "columnName": "heardUs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "starred", + "columnName": "starred", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "comment", + "columnName": "comment", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "callsign" + ] + }, + "indices": [ + { + "name": "index_contacts_lastHeard", + "unique": false, + "columnNames": [ + "lastHeard" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_contacts_lastHeard` ON `${TABLE_NAME}` (`lastHeard`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "mailbox_messages", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `originator` TEXT NOT NULL, `destination` TEXT NOT NULL, `text` TEXT NOT NULL, `receivedAt` INTEGER NOT NULL, `originatedAt` INTEGER, `relayPath` TEXT, `snr` INTEGER, `offsetHz` REAL, `state` INTEGER NOT NULL, `deliveredAt` INTEGER, `origin` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "originator", + "columnName": "originator", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "destination", + "columnName": "destination", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "text", + "columnName": "text", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "receivedAt", + "columnName": "receivedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "originatedAt", + "columnName": "originatedAt", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "relayPath", + "columnName": "relayPath", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "snr", + "columnName": "snr", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "offsetHz", + "columnName": "offsetHz", + "affinity": "REAL", + "notNull": false + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "deliveredAt", + "columnName": "deliveredAt", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "origin", + "columnName": "origin", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_mailbox_messages_destination", + "unique": false, + "columnNames": [ + "destination" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_mailbox_messages_destination` ON `${TABLE_NAME}` (`destination`)" + }, + { + "name": "index_mailbox_messages_state", + "unique": false, + "columnNames": [ + "state" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_mailbox_messages_state` ON `${TABLE_NAME}` (`state`)" + }, + { + "name": "index_mailbox_messages_receivedAt", + "unique": false, + "columnNames": [ + "receivedAt" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_mailbox_messages_receivedAt` ON `${TABLE_NAME}` (`receivedAt`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "mailbox_group_delivery", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`msgId` INTEGER NOT NULL, `callsign` TEXT NOT NULL, `deliveredAt` INTEGER NOT NULL, PRIMARY KEY(`msgId`, `callsign`), FOREIGN KEY(`msgId`) REFERENCES `mailbox_messages`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "msgId", + "columnName": "msgId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "callsign", + "columnName": "callsign", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "deliveredAt", + "columnName": "deliveredAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "msgId", + "callsign" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "mailbox_messages", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "msgId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "conversation_settings", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`conversationId` TEXT NOT NULL, `relayPath` TEXT, PRIMARY KEY(`conversationId`))", + "fields": [ + { + "fieldPath": "conversationId", + "columnName": "conversationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relayPath", + "columnName": "relayPath", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "conversationId" + ] + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'd4b2d1fee965b0fa82c7df79aa2379e5')" + ] + } +} \ No newline at end of file diff --git a/android/app/schemas/com.js8call.example.data.MessageDatabase/7.json b/android/app/schemas/com.js8call.example.data.MessageDatabase/7.json new file mode 100644 index 000000000..3b6cc2192 --- /dev/null +++ b/android/app/schemas/com.js8call.example.data.MessageDatabase/7.json @@ -0,0 +1,475 @@ +{ + "formatVersion": 1, + "database": { + "version": 7, + "identityHash": "0b892323d859868983643bf8df3c726a", + "entities": [ + { + "tableName": "messages", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `conversationId` TEXT NOT NULL, `direction` INTEGER NOT NULL, `senderCallsign` TEXT, `text` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, `snr` INTEGER, `frequency` REAL, `status` INTEGER NOT NULL, `isRead` INTEGER NOT NULL, `relayPath` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "conversationId", + "columnName": "conversationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "direction", + "columnName": "direction", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "senderCallsign", + "columnName": "senderCallsign", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "text", + "columnName": "text", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "snr", + "columnName": "snr", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "frequency", + "columnName": "frequency", + "affinity": "REAL", + "notNull": false + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isRead", + "columnName": "isRead", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "relayPath", + "columnName": "relayPath", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_messages_conversationId", + "unique": false, + "columnNames": [ + "conversationId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_messages_conversationId` ON `${TABLE_NAME}` (`conversationId`)" + }, + { + "name": "index_messages_timestamp", + "unique": false, + "columnNames": [ + "timestamp" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_messages_timestamp` ON `${TABLE_NAME}` (`timestamp`)" + }, + { + "name": "index_messages_isRead", + "unique": false, + "columnNames": [ + "isRead" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_messages_isRead` ON `${TABLE_NAME}` (`isRead`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "contacts", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`callsign` TEXT NOT NULL, `name` TEXT, `lastHeard` INTEGER NOT NULL, `snr` INTEGER, `offset` REAL, `grid` TEXT, `info` TEXT, `heardUs` INTEGER NOT NULL, `starred` INTEGER NOT NULL, `comment` TEXT, PRIMARY KEY(`callsign`))", + "fields": [ + { + "fieldPath": "callsign", + "columnName": "callsign", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastHeard", + "columnName": "lastHeard", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "snr", + "columnName": "snr", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "offset", + "columnName": "offset", + "affinity": "REAL", + "notNull": false + }, + { + "fieldPath": "grid", + "columnName": "grid", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "info", + "columnName": "info", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "heardUs", + "columnName": "heardUs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "starred", + "columnName": "starred", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "comment", + "columnName": "comment", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "callsign" + ] + }, + "indices": [ + { + "name": "index_contacts_lastHeard", + "unique": false, + "columnNames": [ + "lastHeard" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_contacts_lastHeard` ON `${TABLE_NAME}` (`lastHeard`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "mailbox_messages", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `originator` TEXT NOT NULL, `destination` TEXT NOT NULL, `text` TEXT NOT NULL, `receivedAt` INTEGER NOT NULL, `originatedAt` INTEGER, `relayPath` TEXT, `snr` INTEGER, `offsetHz` REAL, `state` INTEGER NOT NULL, `deliveredAt` INTEGER, `origin` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "originator", + "columnName": "originator", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "destination", + "columnName": "destination", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "text", + "columnName": "text", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "receivedAt", + "columnName": "receivedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "originatedAt", + "columnName": "originatedAt", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "relayPath", + "columnName": "relayPath", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "snr", + "columnName": "snr", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "offsetHz", + "columnName": "offsetHz", + "affinity": "REAL", + "notNull": false + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "deliveredAt", + "columnName": "deliveredAt", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "origin", + "columnName": "origin", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_mailbox_messages_destination", + "unique": false, + "columnNames": [ + "destination" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_mailbox_messages_destination` ON `${TABLE_NAME}` (`destination`)" + }, + { + "name": "index_mailbox_messages_state", + "unique": false, + "columnNames": [ + "state" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_mailbox_messages_state` ON `${TABLE_NAME}` (`state`)" + }, + { + "name": "index_mailbox_messages_receivedAt", + "unique": false, + "columnNames": [ + "receivedAt" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_mailbox_messages_receivedAt` ON `${TABLE_NAME}` (`receivedAt`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "mailbox_group_delivery", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`msgId` INTEGER NOT NULL, `callsign` TEXT NOT NULL, `deliveredAt` INTEGER NOT NULL, PRIMARY KEY(`msgId`, `callsign`), FOREIGN KEY(`msgId`) REFERENCES `mailbox_messages`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "msgId", + "columnName": "msgId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "callsign", + "columnName": "callsign", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "deliveredAt", + "columnName": "deliveredAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "msgId", + "callsign" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "mailbox_messages", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "msgId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "conversation_settings", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`conversationId` TEXT NOT NULL, `relayPath` TEXT, PRIMARY KEY(`conversationId`))", + "fields": [ + { + "fieldPath": "conversationId", + "columnName": "conversationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relayPath", + "columnName": "relayPath", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "conversationId" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "link_observations", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `reporter` TEXT NOT NULL, `heard` TEXT NOT NULL, `snr` INTEGER, `source` TEXT NOT NULL, `dialFreqHz` INTEGER, `observedAt` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "reporter", + "columnName": "reporter", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "heard", + "columnName": "heard", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "snr", + "columnName": "snr", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "source", + "columnName": "source", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "dialFreqHz", + "columnName": "dialFreqHz", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "observedAt", + "columnName": "observedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_link_observations_reporter", + "unique": false, + "columnNames": [ + "reporter" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_link_observations_reporter` ON `${TABLE_NAME}` (`reporter`)" + }, + { + "name": "index_link_observations_heard", + "unique": false, + "columnNames": [ + "heard" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_link_observations_heard` ON `${TABLE_NAME}` (`heard`)" + }, + { + "name": "index_link_observations_observedAt", + "unique": false, + "columnNames": [ + "observedAt" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_link_observations_observedAt` ON `${TABLE_NAME}` (`observedAt`)" + } + ], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '0b892323d859868983643bf8df3c726a')" + ] + } +} \ No newline at end of file diff --git a/android/app/src/androidTest/java/com/js8call/example/data/MigrationTest.kt b/android/app/src/androidTest/java/com/js8call/example/data/MigrationTest.kt new file mode 100644 index 000000000..8013009e7 --- /dev/null +++ b/android/app/src/androidTest/java/com/js8call/example/data/MigrationTest.kt @@ -0,0 +1,198 @@ +package com.js8call.example.data + +import androidx.room.testing.MigrationTestHelper +import androidx.sqlite.db.framework.FrameworkSQLiteOpenHelperFactory +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +/** + * The destructive fallback is gone, so a migration gap now crashes on + * upgrade instead of silently wiping the database. These tests are the + * proof each schema bump needs before it ships. + */ +@RunWith(AndroidJUnit4::class) +class MigrationTest { + + private val testDb = "migration-test.db" + + @get:Rule + val helper = MigrationTestHelper( + InstrumentationRegistry.getInstrumentation(), + MessageDatabase::class.java.canonicalName, + FrameworkSQLiteOpenHelperFactory() + ) + + @Test + fun migrate3To4_keepsDataAndAddsMailbox() { + helper.createDatabase(testDb, 3).apply { + execSQL( + """ + INSERT INTO messages + (conversationId, direction, senderCallsign, text, timestamp, + snr, frequency, status, isRead, relayPath) + VALUES ('KN4CRD', 0, 'KN4CRD', 'HELLO', 1724200000000, + -12, 1500.0, 0, 1, NULL) + """.trimIndent() + ) + execSQL( + """ + INSERT INTO contacts (callsign, lastHeard, heardUs, starred) + VALUES ('KN4CRD', 1724200000000, 1, 0) + """.trimIndent() + ) + close() + } + + val db = helper.runMigrationsAndValidate( + testDb, 4, true, MessageDatabase.MIGRATION_3_4 + ) + + db.query("SELECT text FROM messages").use { c -> + assertTrue(c.moveToFirst()) + assertEquals("HELLO", c.getString(0)) + } + db.query("SELECT callsign FROM contacts").use { c -> + assertTrue(c.moveToFirst()) + assertEquals("KN4CRD", c.getString(0)) + } + + // The new tables accept rows, and the delivery cascade holds. + db.execSQL( + """ + INSERT INTO mailbox_messages + (originator, destination, text, receivedAt, state, origin) + VALUES ('KA0XYZ', 'N0CALL', 'QRV 1400', 1724200000000, 0, 0) + """.trimIndent() + ) + db.execSQL( + "INSERT INTO mailbox_group_delivery (msgId, callsign, deliveredAt) VALUES (1, 'N0CALL', 1724200000000)" + ) + db.execSQL("PRAGMA foreign_keys = ON") + db.execSQL("DELETE FROM mailbox_messages WHERE id = 1") + db.query("SELECT COUNT(*) FROM mailbox_group_delivery").use { c -> + assertTrue(c.moveToFirst()) + assertEquals(0, c.getInt(0)) + } + } + + @Test + fun migrate4To5_keepsDataAndAddsThreadSettings() { + helper.createDatabase(testDb, 4).apply { + execSQL( + """ + INSERT INTO messages + (conversationId, direction, senderCallsign, text, timestamp, + snr, frequency, status, isRead, relayPath) + VALUES ('KN4CRD', 0, 'KN4CRD', 'HELLO', 1724200000000, + -12, 1500.0, 0, 1, NULL) + """.trimIndent() + ) + execSQL( + """ + INSERT INTO mailbox_messages + (originator, destination, text, receivedAt, state, origin) + VALUES ('KA0XYZ', 'N0CALL', 'QRV 1400', 1724200000000, 0, 0) + """.trimIndent() + ) + close() + } + + val db = helper.runMigrationsAndValidate( + testDb, 5, true, MessageDatabase.MIGRATION_4_5 + ) + + db.query("SELECT text FROM messages").use { c -> + assertTrue(c.moveToFirst()) + assertEquals("HELLO", c.getString(0)) + } + db.query("SELECT text FROM mailbox_messages").use { c -> + assertTrue(c.moveToFirst()) + assertEquals("QRV 1400", c.getString(0)) + } + + db.execSQL( + "INSERT INTO conversation_settings (conversationId, relayPath) VALUES ('KN4CRD', 'KA0XYZ>N0DEF')" + ) + db.query("SELECT relayPath FROM conversation_settings WHERE conversationId = 'KN4CRD'").use { c -> + assertTrue(c.moveToFirst()) + assertEquals("KA0XYZ>N0DEF", c.getString(0)) + } + } + + @Test + fun migrate5To6_keepsUserDataAndAddsName() { + helper.createDatabase(testDb, 5).apply { + // The star and comment are the operator's, not the radio's, and + // the name column joins them. All three have to survive. + execSQL( + """ + INSERT INTO contacts (callsign, lastHeard, snr, grid, heardUs, starred, comment) + VALUES ('KN4CRD', 1724200000000, -12, 'EM73', 1, 1, 'sked partner') + """.trimIndent() + ) + close() + } + + val db = helper.runMigrationsAndValidate( + testDb, 6, true, MessageDatabase.MIGRATION_5_6 + ) + + db.query("SELECT starred, comment, name FROM contacts WHERE callsign = 'KN4CRD'").use { c -> + assertTrue(c.moveToFirst()) + assertEquals(1, c.getInt(0)) + assertEquals("sked partner", c.getString(1)) + assertTrue("name starts empty", c.isNull(2)) + } + + db.execSQL("UPDATE contacts SET name = 'Jordan' WHERE callsign = 'KN4CRD'") + db.query("SELECT name FROM contacts WHERE callsign = 'KN4CRD'").use { c -> + assertTrue(c.moveToFirst()) + assertEquals("Jordan", c.getString(0)) + } + } + + @Test + fun migrate6To7_keepsDataAndAddsLinkObservations() { + helper.createDatabase(testDb, 6).apply { + execSQL( + """ + INSERT INTO contacts (callsign, lastHeard, heardUs, starred, name) + VALUES ('KN4CRD', 1724200000000, 1, 1, 'Jordan') + """.trimIndent() + ) + close() + } + + val db = helper.runMigrationsAndValidate( + testDb, 7, true, MessageDatabase.MIGRATION_6_7 + ) + + db.query("SELECT name FROM contacts WHERE callsign = 'KN4CRD'").use { c -> + assertTrue(c.moveToFirst()) + assertEquals("Jordan", c.getString(0)) + } + + // The new table accepts a full row and a null-SNR, null-dial row. + db.execSQL( + """ + INSERT INTO link_observations (reporter, heard, snr, source, dialFreqHz, observedAt) + VALUES ('N0DEF', 'KA0XYZ', 5, 'HB_ACK', 7078000, 1724200000000) + """.trimIndent() + ) + db.execSQL( + """ + INSERT INTO link_observations (reporter, heard, snr, source, dialFreqHz, observedAt) + VALUES ('N0DEF', 'W1AW', NULL, 'HEARING', NULL, 1724200000000) + """.trimIndent() + ) + db.query("SELECT COUNT(*) FROM link_observations").use { c -> + assertTrue(c.moveToFirst()) + assertEquals(2, c.getInt(0)) + } + } +} diff --git a/android/app/src/debug/AndroidManifest.xml b/android/app/src/debug/AndroidManifest.xml new file mode 100644 index 000000000..b9e4d83a2 --- /dev/null +++ b/android/app/src/debug/AndroidManifest.xml @@ -0,0 +1,16 @@ + + + + + + + + + + + + + diff --git a/android/app/src/debug/java/com/js8call/example/debug/DebugDecodeReceiver.kt b/android/app/src/debug/java/com/js8call/example/debug/DebugDecodeReceiver.kt new file mode 100644 index 000000000..47fc53c71 --- /dev/null +++ b/android/app/src/debug/java/com/js8call/example/debug/DebugDecodeReceiver.kt @@ -0,0 +1,37 @@ +package com.js8call.example.debug + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.util.Log +import com.js8call.example.service.JS8EngineService + +/** + * Debug-only bridge from adb to the decode path. The engine service is not + * exported, so the shell cannot start it directly; this receiver is, and + * forwards the extras on. + * + * adb shell am broadcast -a com.js8call.example.DEBUG_INJECT_DECODE \ + * -n com.js8call.example/.debug.DebugDecodeReceiver \ + * --es text 'KN4CRD: N0CALL MSG TO:KA0XYZ HELLO' --ei type 1 + * + * Extras mirror the decode broadcast: text, snr, freq, type, mode. + * + * The app must be foregrounded when the broadcast arrives; a background + * startService is refused and the receiver crash takes the process with it. + */ +class DebugDecodeReceiver : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + val forward = Intent(context, JS8EngineService::class.java).apply { + action = JS8EngineService.ACTION_DEBUG_INJECT_DECODE + intent.extras?.let { putExtras(it) } + } + try { + context.startService(forward) + } catch (e: Exception) { + // A background start is refused; without this catch the crash + // takes the whole process down and the app appears broken. + Log.w("DebugDecodeReceiver", "Injection refused, app not foregrounded: $e") + } + } +} diff --git a/android/app/src/main/java/com/js8call/example/MainActivity.kt b/android/app/src/main/java/com/js8call/example/MainActivity.kt index 24dac83c0..ca3d1b373 100644 --- a/android/app/src/main/java/com/js8call/example/MainActivity.kt +++ b/android/app/src/main/java/com/js8call/example/MainActivity.kt @@ -8,18 +8,27 @@ import android.content.IntentFilter import android.content.pm.PackageManager import android.os.Build import android.os.Bundle +import android.view.MenuItem +import android.view.View import android.view.WindowManager import androidx.appcompat.app.AppCompatActivity import androidx.core.app.ActivityCompat import androidx.core.content.ContextCompat import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.lifecycleScope import androidx.localbroadcastmanager.content.LocalBroadcastManager +import kotlinx.coroutines.launch +import androidx.navigation.NavController import androidx.navigation.fragment.NavHostFragment +import androidx.navigation.ui.NavigationUI import androidx.navigation.ui.setupWithNavController import androidx.preference.PreferenceManager import com.google.android.material.bottomnavigation.BottomNavigationView +import com.google.android.material.navigation.NavigationBarView import com.google.android.material.snackbar.Snackbar +import com.js8call.example.data.MailboxRepository import com.js8call.example.model.EngineState +import com.js8call.example.model.TransmitMessage import com.js8call.example.service.JS8EngineService import com.js8call.example.ui.DecodeViewModel import com.js8call.example.ui.MessagesViewModel @@ -28,12 +37,13 @@ import com.js8call.example.ui.TransmitViewModel class MainActivity : AppCompatActivity() { - private lateinit var bottomNav: BottomNavigationView + private lateinit var bottomNav: NavigationBarView private lateinit var decodeViewModel: DecodeViewModel private lateinit var monitorViewModel: MonitorViewModel private var spectrumBroadcastCount: Long = 0 private lateinit var messagesViewModel: MessagesViewModel private lateinit var transmitViewModel: TransmitViewModel + private val mailboxRepository by lazy { MailboxRepository(this) } private val decodeReceiver = object : BroadcastReceiver() { override fun onReceive(context: Context, intent: Intent) { @@ -58,13 +68,38 @@ class MainActivity : AppCompatActivity() { val freq = intent.getFloatExtra(JS8EngineService.EXTRA_MESSAGE_FREQ, 0f) val relayPath = intent.getStringExtra(JS8EngineService.EXTRA_MESSAGE_RELAY_PATH) val conversationId = intent.getStringExtra(JS8EngineService.EXTRA_MESSAGE_CONVERSATION_ID) ?: from - messagesViewModel.insertIncomingMessage(conversationId, from, msgText, snr, freq, relayPath) + val silent = intent.getBooleanExtra(JS8EngineService.EXTRA_MESSAGE_SILENT, false) + messagesViewModel.insertIncomingMessage( + conversationId, from, msgText, snr, freq, relayPath, + // Other-group traffic arrives read, so it never + // counts toward the tab badge. + markRead = silent + ) + } + JS8EngineService.ACTION_MESSAGE_ACKED -> { + val from = intent.getStringExtra(JS8EngineService.EXTRA_MESSAGE_FROM) ?: return + messagesViewModel.markLatestSentAcked(from) + } + JS8EngineService.ACTION_MAILBOX_EMPTY -> { + val station = intent.getStringExtra(JS8EngineService.EXTRA_MESSAGE_FROM) ?: return + Snackbar.make( + findViewById(android.R.id.content), + getString(R.string.mailbox_none_waiting, station), + Snackbar.LENGTH_LONG + ).show() } JS8EngineService.ACTION_QUEUE_TX -> { val text = intent.getStringExtra(JS8EngineService.EXTRA_QUEUE_TX_TEXT) ?: return val directed = intent.getStringExtra(JS8EngineService.EXTRA_QUEUE_TX_DIRECTED) val priority = intent.getIntExtra(JS8EngineService.EXTRA_QUEUE_TX_PRIORITY, 0) - transmitViewModel.queueMessage(text, directed, priority, clearComposed = false) + val mailboxId = intent.getLongExtra(JS8EngineService.EXTRA_QUEUE_TX_MAILBOX_ID, -1L) + .takeIf { it > 0 } + val mailboxRecipient = + intent.getStringExtra(JS8EngineService.EXTRA_QUEUE_TX_MAILBOX_RECIPIENT) + transmitViewModel.queueMessage( + text, directed, priority, + mailboxId = mailboxId, mailboxRecipient = mailboxRecipient + ) // Trigger queue processing processNextTxIfIdle() } @@ -72,11 +107,27 @@ class MainActivity : AppCompatActivity() { val state = intent.getStringExtra(JS8EngineService.EXTRA_TX_STATE) when (state) { JS8EngineService.TX_STATE_FINISHED, JS8EngineService.TX_STATE_FAILED -> { - // Update ViewModel state first + // Update ViewModel state first; a finished send that + // belongs to a conversation gets its bubble updated. if (state == JS8EngineService.TX_STATE_FINISHED) { - transmitViewModel.transmissionComplete() + val finished = transmitViewModel.transmissionComplete() + finished?.dbId?.let { dbId -> + messagesViewModel.updateMessageStatus( + dbId, + com.js8call.example.data.MessageEntity.STATUS_SENT + ) + } + // Mailbox mail counts as delivered only once + // its transmission finished; a failed send + // stays held for the next query. + finished?.let { markMailboxDelivered(it) } } else { - transmitViewModel.transmissionFailed() + transmitViewModel.transmissionFailed()?.dbId?.let { dbId -> + messagesViewModel.updateMessageStatus( + dbId, + com.js8call.example.data.MessageEntity.STATUS_FAILED + ) + } } // Process next item in queue after TX completes processNextTxIfIdle() @@ -89,6 +140,16 @@ class MainActivity : AppCompatActivity() { } } } + JS8EngineService.ACTION_TX_PROGRESS -> { + val frameIndex = intent.getIntExtra(JS8EngineService.EXTRA_TX_FRAME_INDEX, 0) + val frameCount = intent.getIntExtra(JS8EngineService.EXTRA_TX_FRAME_COUNT, 0) + transmitViewModel.setTxProgress(frameIndex, frameCount) + } + JS8EngineService.ACTION_TX_SENT -> { + val text = intent.getStringExtra(JS8EngineService.EXTRA_TX_SENT_TEXT) ?: return + val freq = intent.getFloatExtra(JS8EngineService.EXTRA_TX_SENT_FREQ, 0f) + decodeViewModel.addOutgoing(text, freq) + } ACTION_PROCESS_TX_QUEUE -> { android.util.Log.d("MainActivity", "Received ACTION_PROCESS_TX_QUEUE") processNextTxIfIdle() @@ -147,6 +208,28 @@ class MainActivity : AppCompatActivity() { val driftMs = intent.getLongExtra(JS8EngineService.EXTRA_TIME_DRIFT_MS, 0L) monitorViewModel.updateTimeDrift(driftMs) } + JS8EngineService.ACTION_TIMING_SUGGESTION -> { + val kind = intent.getIntExtra(JS8EngineService.EXTRA_TIMING_KIND, 0) + monitorViewModel.updateTimingSuggestion( + if (kind == JS8EngineService.TIMING_GAVE_UP) { + null + } else { + MonitorViewModel.TimingSuggestion( + kind = kind, + driftMs = intent.getLongExtra(JS8EngineService.EXTRA_TIME_DRIFT_MS, 0L), + step = intent.getIntExtra(JS8EngineService.EXTRA_TIMING_STEP, 0), + steps = intent.getIntExtra(JS8EngineService.EXTRA_TIMING_STEPS, 0), + periodMs = intent.getIntExtra( + JS8EngineService.EXTRA_TIMING_PERIOD_MS, 15_000 + ) + ) + } + ) + } + JS8EngineService.ACTION_RIG_STATUS -> { + val connected = intent.getBooleanExtra(JS8EngineService.EXTRA_RIG_CONNECTED, false) + monitorViewModel.updateRigConnected(connected) + } } } } @@ -174,16 +257,28 @@ class MainActivity : AppCompatActivity() { val navHostFragment = supportFragmentManager .findFragmentById(R.id.nav_host_fragment) as NavHostFragment val navController = navHostFragment.navController + mainNavController = navController bottomNav.setupWithNavController(navController) navController.addOnDestinationChangedListener { _, destination, _ -> - if (destination.id == R.id.navigation_conversation) { + renderTimingSurface() + if (destination.id == R.id.navigation_conversation || + destination.id == R.id.navigation_everything + ) { bottomNav.menu.findItem(R.id.navigation_messages).isChecked = true } } + // Both listeners share one handler. A tab is checked only when its own + // destination is showing, so a tap arrives as select or reselect + // depending on where the user is. + bottomNav.setOnItemSelectedListener { item -> onNavItemTapped(navController, item) } + bottomNav.setOnItemReselectedListener { item -> onNavItemTapped(navController, item) } + + openThreadFromNotification(intent, navController) decodeViewModel = ViewModelProvider(this)[DecodeViewModel::class.java] monitorViewModel = ViewModelProvider(this)[MonitorViewModel::class.java] + observeTimingSuggestion() messagesViewModel = ViewModelProvider(this)[MessagesViewModel::class.java] transmitViewModel = ViewModelProvider(this)[TransmitViewModel::class.java] decodeViewModel.loadPersistedDecodesIfEnabled() @@ -214,8 +309,12 @@ class MainActivity : AppCompatActivity() { val filter = IntentFilter().apply { addAction(JS8EngineService.ACTION_DECODE) addAction(JS8EngineService.ACTION_MESSAGE_RECEIVED) + addAction(JS8EngineService.ACTION_MESSAGE_ACKED) + addAction(JS8EngineService.ACTION_MAILBOX_EMPTY) addAction(JS8EngineService.ACTION_QUEUE_TX) addAction(JS8EngineService.ACTION_TX_STATE) + addAction(JS8EngineService.ACTION_TX_SENT) + addAction(JS8EngineService.ACTION_TX_PROGRESS) addAction(ACTION_PROCESS_TX_QUEUE) } LocalBroadcastManager.getInstance(this) @@ -228,6 +327,8 @@ class MainActivity : AppCompatActivity() { addAction(JS8EngineService.ACTION_ERROR) addAction(JS8EngineService.ACTION_RADIO_FREQUENCY) addAction(JS8EngineService.ACTION_TIME_DRIFT) + addAction(JS8EngineService.ACTION_TIMING_SUGGESTION) + addAction(JS8EngineService.ACTION_RIG_STATUS) } LocalBroadcastManager.getInstance(this) .registerReceiver(monitorReceiver, monitorFilter) @@ -247,9 +348,139 @@ class MainActivity : AppCompatActivity() { super.onStop() } + /** + * Handle a tap on a bottom navigation or navigation rail item. + * + * Pops back to the destination when it is already on the back stack. + * NavigationUI navigates with popUpTo(start) and saveState instead, which + * leaves the current fragment on screen when the target is the start + * destination sitting under it: the controller moves but the view does not. + * Opening All activity from the Monitor header lands in exactly that case. + */ + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + applyDebugTimingSuggestion(intent) + val navHostFragment = supportFragmentManager + .findFragmentById(R.id.nav_host_fragment) as? NavHostFragment ?: return + openThreadFromNotification(intent, navHostFragment.navController) + } + + /** + * A timing fix is worth knowing about from anywhere, but it is acted on + * from Monitor. So: a badge on the Monitor tab whenever one is pending, a + * snackbar only when the user is somewhere else, and its action just takes + * them to the card rather than applying a correction out of context. + */ + private var timingSnackbar: Snackbar? = null + private var mainNavController: NavController? = null + + private fun observeTimingSuggestion() { + monitorViewModel.timingSuggestion.observe(this) { renderTimingSurface() } + } + + /** + * Called on a new suggestion and on every navigation, because moving off + * Monitor is what makes the snackbar the right surface and moving back is + * what retires it. Neither is a change to the suggestion itself. + */ + private fun renderTimingSurface() { + // The destination listener fires while the activity is still wiring up + if (!::monitorViewModel.isInitialized) return + val suggestion = monitorViewModel.timingSuggestion.value + val pending = suggestion != null && + suggestion.kind == JS8EngineService.TIMING_FOUND + + if (pending) { + bottomNav.getOrCreateBadge(R.id.navigation_monitor) + } else { + bottomNav.removeBadge(R.id.navigation_monitor) + } + + val onMonitor = mainNavController?.currentDestination?.id == R.id.navigation_monitor + if (!pending || onMonitor) { + timingSnackbar?.dismiss() + timingSnackbar = null + return + } + if (timingSnackbar?.isShown == true) return + + val shift = String.format("%.1f s", kotlin.math.abs(suggestion!!.driftMs) / 1000.0) + timingSnackbar = Snackbar.make( + findViewById(android.R.id.content), + getString(R.string.monitor_timing_found, shift), + Snackbar.LENGTH_INDEFINITE + ).apply { + anchorView = bottomNav as? BottomNavigationView + setAction(R.string.monitor_timing_show) { + mainNavController?.navigate(R.id.navigation_monitor) + } + show() + } + } + + /** + * Debug builds only: drive the timing banner straight from an intent, so + * the UI can be checked without waiting out a decode drought and hoping a + * shifted window lands a decode. Same LiveData the real path writes. + */ + private fun applyDebugTimingSuggestion(intent: Intent) { + if (!BuildConfig.DEBUG) return + if (!intent.hasExtra("debug_timing_kind")) return + val kind = intent.getIntExtra("debug_timing_kind", JS8EngineService.TIMING_FOUND) + monitorViewModel.updateTimingSuggestion( + if (kind == JS8EngineService.TIMING_GAVE_UP) { + null + } else { + MonitorViewModel.TimingSuggestion( + kind = kind, + driftMs = intent.getLongExtra("debug_timing_drift", -5500L), + step = intent.getIntExtra("debug_timing_step", 1), + steps = intent.getIntExtra("debug_timing_steps", 3), + periodMs = intent.getIntExtra("debug_timing_period", 15_000) + ) + } + ) + } + + /** + * A message notification carries the thread it belongs to. These extras + * were written but never read before, so tapping only opened the app. + */ + private fun openThreadFromNotification(intent: Intent?, navController: NavController) { + if (intent?.getBooleanExtra("open_messages", false) != true) return + val conversationId = intent.getStringExtra("callsign") ?: return + intent.removeExtra("open_messages") + navController.navigate( + R.id.navigation_conversation, + Bundle().apply { putString("callsign", conversationId) } + ) + } + + private fun onNavItemTapped(navController: NavController, item: MenuItem): Boolean { + if (navController.popBackStack(item.itemId, false)) return true + return NavigationUI.onNavDestinationSelected(item, navController) + } + /** * Process the next message in the TX queue if not currently transmitting. */ + /** + * A finished send that delivered mailbox mail: mark the row. A recipient + * callsign means group mail, recorded per collector; without one the + * message itself is marked delivered. + */ + private fun markMailboxDelivered(finished: TransmitMessage) { + val mailboxId = finished.mailboxId ?: return + lifecycleScope.launch { + val recipient = finished.mailboxRecipient + if (recipient != null) { + mailboxRepository.recordGroupDelivery(mailboxId, recipient) + } else { + mailboxRepository.markDelivered(mailboxId) + } + } + } + private fun processNextTxIfIdle() { // Don't send if already transmitting val state = transmitViewModel.txState.value diff --git a/android/app/src/main/java/com/js8call/example/data/ContactDao.kt b/android/app/src/main/java/com/js8call/example/data/ContactDao.kt new file mode 100644 index 000000000..2d3a356ba --- /dev/null +++ b/android/app/src/main/java/com/js8call/example/data/ContactDao.kt @@ -0,0 +1,61 @@ +package com.js8call.example.data + +import androidx.lifecycle.LiveData +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query + +/** + * Data Access Object for heard-station contacts. + */ +@Dao +interface ContactDao { + + @Query("SELECT * FROM contacts ORDER BY starred DESC, lastHeard DESC") + fun getContacts(): LiveData> + + @Query("SELECT * FROM contacts WHERE callsign = :callsign") + suspend fun getContact(callsign: String): ContactEntity? + + /** Null until the station has been heard, or named on its contact card. */ + @Query("SELECT * FROM contacts WHERE callsign = :callsign") + fun getContactLive(callsign: String): LiveData + + @Insert(onConflict = OnConflictStrategy.IGNORE) + suspend fun insertIgnore(contact: ContactEntity): Long + + /** Refresh the heard fields, keeping star, comment, and heardUs. */ + @Query(""" + UPDATE contacts + SET lastHeard = :timestamp, + snr = :snr, + offset = :offset, + grid = COALESCE(:grid, grid), + info = COALESCE(:info, info) + WHERE callsign = :callsign + """) + suspend fun updateHeard( + callsign: String, + timestamp: Long, + snr: Int?, + offset: Float?, + grid: String?, + info: String? + ) + + @Query("UPDATE contacts SET heardUs = 1 WHERE callsign = :callsign") + suspend fun markHeardUs(callsign: String) + + @Query("UPDATE contacts SET starred = :starred WHERE callsign = :callsign") + suspend fun setStarred(callsign: String, starred: Boolean) + + @Query("UPDATE contacts SET comment = :comment WHERE callsign = :callsign") + suspend fun setComment(callsign: String, comment: String?) + + @Query("UPDATE contacts SET name = :name WHERE callsign = :callsign") + suspend fun setName(callsign: String, name: String?) + + @Query("DELETE FROM contacts WHERE callsign = :callsign") + suspend fun deleteContact(callsign: String) +} diff --git a/android/app/src/main/java/com/js8call/example/data/ContactEntity.kt b/android/app/src/main/java/com/js8call/example/data/ContactEntity.kt new file mode 100644 index 000000000..b1c82b7b9 --- /dev/null +++ b/android/app/src/main/java/com/js8call/example/data/ContactEntity.kt @@ -0,0 +1,48 @@ +package com.js8call.example.data + +import androidx.room.Entity +import androidx.room.Index +import androidx.room.PrimaryKey + +/** + * A station heard on the air, updated on every decode from it. + * Name, star and comment are user data and survive heard updates. + */ +@Entity( + tableName = "contacts", + indices = [Index(value = ["lastHeard"])] +) +data class ContactEntity( + @PrimaryKey + val callsign: String, + + /** + * What the operator calls this station. Shown in place of the callsign + * wherever a station is named, with the callsign kept underneath. + */ + val name: String? = null, + + /** UTC timestamp in milliseconds of the newest decode from this station */ + val lastHeard: Long, + + /** SNR of the newest decode */ + val snr: Int? = null, + + /** Audio frequency offset in Hz of the newest decode */ + val offset: Float? = null, + + /** Maidenhead grid, kept from the newest message that carried one */ + val grid: String? = null, + + /** Station info text, kept from the newest INFO reply heard */ + val info: String? = null, + + /** True once the station has sent a message directed at our callsign */ + val heardUs: Boolean = false, + + /** User favorite flag */ + val starred: Boolean = false, + + /** User note */ + val comment: String? = null +) diff --git a/android/app/src/main/java/com/js8call/example/data/ContactRepository.kt b/android/app/src/main/java/com/js8call/example/data/ContactRepository.kt new file mode 100644 index 000000000..d8fa1d3ea --- /dev/null +++ b/android/app/src/main/java/com/js8call/example/data/ContactRepository.kt @@ -0,0 +1,133 @@ +package com.js8call.example.data + +import android.content.Context +import androidx.lifecycle.LiveData +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +/** + * Repository for heard-station contacts. Every complete decode passes + * through [recordDecode], which parses out the sender, an optional grid, + * and whether the message was directed at our callsign. + */ +class ContactRepository(context: Context) { + + private val contactDao = MessageDatabase.getInstance(context).contactDao() + + fun getContacts(): LiveData> = contactDao.getContacts() + + fun getContactLive(callsign: String): LiveData = + contactDao.getContactLive(callsign.trim().uppercase()) + + suspend fun setStarred(callsign: String, starred: Boolean) { + withContext(Dispatchers.IO) { + ensureRow(callsign) + contactDao.setStarred(callsign, starred) + } + } + + suspend fun setComment(callsign: String, comment: String?) { + withContext(Dispatchers.IO) { + ensureRow(callsign) + contactDao.setComment(callsign, comment) + } + } + + suspend fun setName(callsign: String, name: String?) { + withContext(Dispatchers.IO) { + ensureRow(callsign) + contactDao.setName(callsign, name) + } + } + + /** + * Rows are normally written by [recordDecode], so a station named from a + * thread the app restored but never heard has no row to update yet. + * lastHeard of 0 marks it as never actually heard on the air. + */ + private suspend fun ensureRow(callsign: String) { + contactDao.insertIgnore(ContactEntity(callsign = callsign, lastHeard = 0L)) + } + + suspend fun deleteContact(callsign: String) { + withContext(Dispatchers.IO) { contactDao.deleteContact(callsign) } + } + + /** + * Update the sender's contact entry from one complete decoded message. + * Does nothing when the text does not start with a callsign. + */ + suspend fun recordDecode( + text: String, + snr: Int, + offsetHz: Float, + timestamp: Long, + myCallsign: String? + ) { + val trimmed = text.trim() + val tokens = trimmed.split(Regex("\\s+")) + if (tokens.isEmpty()) return + + val sender = tokens[0].trimEnd(':').uppercase() + if (!isCallsignLike(sender)) return + + val grid = tokens.lastOrNull()?.uppercase() + ?.takeIf { it != "RR73" && gridRegex.matches(it) } + val info = parseInfoReply(tokens) + + withContext(Dispatchers.IO) { + val inserted = contactDao.insertIgnore( + ContactEntity( + callsign = sender, + lastHeard = timestamp, + snr = snr, + offset = offsetHz, + grid = grid, + info = info + ) + ) + if (inserted == -1L) { + contactDao.updateHeard(sender, timestamp, snr, offsetHz, grid, info) + } + + // "SENDER: MYCALL ..." means the station copied us. + if (!myCallsign.isNullOrBlank() && + tokens[0].endsWith(":") && + tokens.getOrNull(1)?.uppercase() == myCallsign.uppercase() + ) { + contactDao.markHeardUs(sender) + } + } + } + + /** + * Station info from an INFO reply: "SENDER: TARGET INFO ". + * The INFO? query itself carries no text and does not match. + */ + private fun parseInfoReply(tokens: List): String? { + if (tokens.size < 4) return null + if (!tokens[0].endsWith(":")) return null + if (tokens[2].uppercase() != "INFO") return null + return tokens.drop(3).joinToString(" ").takeIf { it.isNotBlank() } + } + + private fun isCallsignLike(token: String): Boolean { + if (token.length !in 3..12) return false + if (!callsignRegex.matches(token)) return false + return token.any { it.isLetter() } && token.any { it.isDigit() } + } + + companion object { + private val callsignRegex = Regex("^[A-Z0-9/]+$") + private val gridRegex = Regex("^[A-R]{2}[0-9]{2}([A-X]{2})?$") + + @Volatile + private var INSTANCE: ContactRepository? = null + + fun getInstance(context: Context): ContactRepository { + return INSTANCE ?: synchronized(this) { + INSTANCE ?: ContactRepository(context.applicationContext).also { INSTANCE = it } + } + } + } +} diff --git a/android/app/src/main/java/com/js8call/example/data/ConversationSettingsDao.kt b/android/app/src/main/java/com/js8call/example/data/ConversationSettingsDao.kt new file mode 100644 index 000000000..ee9420f7f --- /dev/null +++ b/android/app/src/main/java/com/js8call/example/data/ConversationSettingsDao.kt @@ -0,0 +1,23 @@ +package com.js8call.example.data + +import androidx.lifecycle.LiveData +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query + +@Dao +interface ConversationSettingsDao { + + @Query("SELECT relayPath FROM conversation_settings WHERE conversationId = :conversationId") + fun getRelayPath(conversationId: String): LiveData + + @Query("SELECT relayPath FROM conversation_settings WHERE conversationId = :conversationId") + suspend fun getRelayPathOnce(conversationId: String): String? + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun upsert(settings: ConversationSettingsEntity) + + @Query("DELETE FROM conversation_settings WHERE conversationId = :conversationId") + suspend fun delete(conversationId: String) +} diff --git a/android/app/src/main/java/com/js8call/example/data/ConversationSettingsEntity.kt b/android/app/src/main/java/com/js8call/example/data/ConversationSettingsEntity.kt new file mode 100644 index 000000000..827a19c38 --- /dev/null +++ b/android/app/src/main/java/com/js8call/example/data/ConversationSettingsEntity.kt @@ -0,0 +1,23 @@ +package com.js8call.example.data + +import androidx.room.Entity +import androidx.room.PrimaryKey + +/** + * Per-thread settings that are the operator's choice rather than something + * heard on the air. Kept apart from [ContactEntity], whose rows are rewritten + * on every decode. + */ +@Entity(tableName = "conversation_settings") +data class ConversationSettingsEntity( + + /** Callsign or @GROUP name, matching MessageEntity.conversationId */ + @PrimaryKey + val conversationId: String, + + /** + * Ordered relay hops in `A>B` notation, nearest hop first, excluding the + * destination. Null or empty means transmit direct. + */ + val relayPath: String? = null +) diff --git a/android/app/src/main/java/com/js8call/example/data/LinkObservationDao.kt b/android/app/src/main/java/com/js8call/example/data/LinkObservationDao.kt new file mode 100644 index 000000000..5381be130 --- /dev/null +++ b/android/app/src/main/java/com/js8call/example/data/LinkObservationDao.kt @@ -0,0 +1,28 @@ +package com.js8call.example.data + +import androidx.lifecycle.LiveData +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.Query + +@Dao +interface LinkObservationDao { + + @Insert + suspend fun insertAll(observations: List) + + @Query("SELECT * FROM link_observations WHERE observedAt >= :since ORDER BY observedAt DESC") + suspend fun getSince(since: Long): List + + @Query("SELECT * FROM link_observations WHERE (reporter = :callsign OR heard = :callsign) AND observedAt >= :since ORDER BY observedAt DESC") + suspend fun getForStation(callsign: String, since: Long): List + + @Query("SELECT COUNT(*) FROM link_observations") + fun countLive(): LiveData + + @Query("DELETE FROM link_observations WHERE observedAt < :cutoff") + suspend fun deleteOlderThan(cutoff: Long) + + @Query("DELETE FROM link_observations") + suspend fun deleteAll() +} diff --git a/android/app/src/main/java/com/js8call/example/data/LinkObservationEntity.kt b/android/app/src/main/java/com/js8call/example/data/LinkObservationEntity.kt new file mode 100644 index 000000000..7e3344edf --- /dev/null +++ b/android/app/src/main/java/com/js8call/example/data/LinkObservationEntity.kt @@ -0,0 +1,44 @@ +package com.js8call.example.data + +import androidx.room.Entity +import androidx.room.Index +import androidx.room.PrimaryKey + +/** + * One piece of who-hears-whom evidence mined from a decoded frame. Rows are + * append-only and pruned by age: the network map and the path recommender + * aggregate at query time, and keeping the raw observations is what lets them + * weight by recency instead of trusting a link heard once, hours ago. + * + * Links are directed: [reporter] heard [heard], not the other way around. + */ +@Entity( + tableName = "link_observations", + indices = [Index("reporter"), Index("heard"), Index("observedAt")] +) +data class LinkObservationEntity( + + @PrimaryKey(autoGenerate = true) + val id: Long = 0, + + /** The station that heard. */ + val reporter: String, + + /** The station it heard. */ + val heard: String, + + /** Reported or measured SNR in dB. Null when the evidence has no number. */ + val snr: Int?, + + /** A [com.js8call.example.util.LinkEvidence.Source] name. */ + val source: String, + + /** + * Dial frequency in Hz when the frame was decoded, so links stay scoped + * to the band they were observed on. Null when no rig control is active + * and the dial is unknown. + */ + val dialFreqHz: Long?, + + val observedAt: Long +) diff --git a/android/app/src/main/java/com/js8call/example/data/LinkRepository.kt b/android/app/src/main/java/com/js8call/example/data/LinkRepository.kt new file mode 100644 index 000000000..e334a0e38 --- /dev/null +++ b/android/app/src/main/java/com/js8call/example/data/LinkRepository.kt @@ -0,0 +1,48 @@ +package com.js8call.example.data + +import android.content.Context +import androidx.lifecycle.LiveData +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +/** + * Repository for link observations, the who-hears-whom evidence behind the + * network map. Writing happens in the service as frames are decoded; pruning + * runs on service start against the operator's retention setting. + */ +class LinkRepository(context: Context) { + + private val dao = MessageDatabase.getInstance(context).linkObservationDao() + + suspend fun record(observations: List) { + if (observations.isEmpty()) return + withContext(Dispatchers.IO) { dao.insertAll(observations) } + } + + suspend fun getSince(since: Long): List = + withContext(Dispatchers.IO) { dao.getSince(since) } + + suspend fun getForStation(callsign: String, since: Long): List = + withContext(Dispatchers.IO) { dao.getForStation(callsign.trim().uppercase(), since) } + + fun countLive(): LiveData = dao.countLive() + + suspend fun pruneOlderThan(retentionMs: Long) { + withContext(Dispatchers.IO) { dao.deleteOlderThan(System.currentTimeMillis() - retentionMs) } + } + + suspend fun clear() { + withContext(Dispatchers.IO) { dao.deleteAll() } + } + + companion object { + @Volatile + private var INSTANCE: LinkRepository? = null + + fun getInstance(context: Context): LinkRepository { + return INSTANCE ?: synchronized(this) { + INSTANCE ?: LinkRepository(context.applicationContext).also { INSTANCE = it } + } + } + } +} diff --git a/android/app/src/main/java/com/js8call/example/data/MailboxDao.kt b/android/app/src/main/java/com/js8call/example/data/MailboxDao.kt new file mode 100644 index 000000000..5279cb7a4 --- /dev/null +++ b/android/app/src/main/java/com/js8call/example/data/MailboxDao.kt @@ -0,0 +1,122 @@ +package com.js8call.example.data + +import androidx.lifecycle.LiveData +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query + +/** One group message and how many stations have collected it. */ +data class DeliveryCount(val msgId: Long, val count: Int) + +/** + * Data Access Object for the store-and-forward mailbox. + * + * The next/lookahead queries drive the QUERY MSGS and QUERY MSG {id} + * replies. Both take an afterId cursor: pass 0 for the first message, + * then the id just delivered to walk the NEXT MSG ID chain. + */ +@Dao +interface MailboxDao { + + // ---- serving an individual station ---- + + @Query( + """ + SELECT * FROM mailbox_messages + WHERE state = 0 AND destination = :callsign AND id > :afterId + ORDER BY id ASC LIMIT 1 + """ + ) + suspend fun nextForCallsign(callsign: String, afterId: Long = 0): MailboxEntity? + + // ---- serving a group ---- + + /** + * The next group message [callsign] has not collected yet. Only messages + * received after [since] are offered; desktop limits group retrieval to + * the last 48 hours. + */ + @Query( + """ + SELECT m.* FROM mailbox_messages m + LEFT JOIN mailbox_group_delivery d + ON d.msgId = m.id AND d.callsign = :callsign + WHERE m.state = 0 AND m.destination = :groupName + AND m.receivedAt >= :since AND m.id > :afterId + AND d.callsign IS NULL + ORDER BY m.id ASC LIMIT 1 + """ + ) + suspend fun nextGroupForCallsign( + groupName: String, + callsign: String, + since: Long, + afterId: Long = 0 + ): MailboxEntity? + + /** + * The next message [callsign] may collect, individual or group. This is + * the query behind QUERY MSGS and the NEXT MSG ID lookahead: their own + * mail by destination, plus any group message inside the retrieval + * window they have not collected yet. + */ + @Query( + """ + SELECT m.* FROM mailbox_messages m + LEFT JOIN mailbox_group_delivery d + ON d.msgId = m.id AND d.callsign = :callsign + WHERE m.state = 0 AND m.id > :afterId AND ( + m.destination = :callsign + OR (m.destination LIKE '@%' AND m.receivedAt >= :since + AND d.callsign IS NULL) + ) + ORDER BY m.id ASC LIMIT 1 + """ + ) + suspend fun nextForRecipient(callsign: String, since: Long, afterId: Long = 0): MailboxEntity? + + @Query("SELECT * FROM mailbox_messages WHERE id = :id") + suspend fun getById(id: Long): MailboxEntity? + + @Query( + "SELECT COUNT(*) FROM mailbox_group_delivery WHERE msgId = :msgId AND callsign = :callsign" + ) + suspend fun hasGroupDelivery(msgId: Long, callsign: String): Int + + // ---- state changes ---- + + @Insert + suspend fun insert(message: MailboxEntity): Long + + @Query("UPDATE mailbox_messages SET state = 1, deliveredAt = :at WHERE id = :id") + suspend fun markDelivered(id: Long, at: Long) + + @Insert(onConflict = OnConflictStrategy.IGNORE) + suspend fun recordGroupDelivery(delivery: MailboxGroupDeliveryEntity) + + // ---- the Held messages screen ---- + + @Query("SELECT * FROM mailbox_messages ORDER BY receivedAt DESC") + fun getAll(): LiveData> + + @Query("SELECT COUNT(*) FROM mailbox_messages WHERE state = 0") + fun getHeldCount(): LiveData + + /** How much we are holding for one station, for its contact card. */ + @Query("SELECT COUNT(*) FROM mailbox_messages WHERE state = 0 AND destination = :callsign") + fun getHeldCountFor(callsign: String): LiveData + + @Query("SELECT COUNT(*) FROM mailbox_group_delivery WHERE msgId = :msgId") + suspend fun deliveryCount(msgId: Long): Int + + /** How many stations have collected each group message. */ + @Query("SELECT msgId, COUNT(*) AS count FROM mailbox_group_delivery GROUP BY msgId") + fun getDeliveryCounts(): LiveData> + + @Query("DELETE FROM mailbox_messages WHERE id = :id") + suspend fun delete(id: Long) + + @Query("DELETE FROM mailbox_messages WHERE state = 1") + suspend fun deleteDelivered() +} diff --git a/android/app/src/main/java/com/js8call/example/data/MailboxEntity.kt b/android/app/src/main/java/com/js8call/example/data/MailboxEntity.kt new file mode 100644 index 000000000..5036a271c --- /dev/null +++ b/android/app/src/main/java/com/js8call/example/data/MailboxEntity.kt @@ -0,0 +1,73 @@ +package com.js8call.example.data + +import androidx.room.Entity +import androidx.room.Index +import androidx.room.PrimaryKey + +/** + * A store-and-forward message this station holds for someone else. + * + * These are deliberately not [MessageEntity] rows: a held message has an + * originator and a destination, neither of which is us, and putting it in + * the messages table would manufacture phantom conversations. + * + * The id is AUTOINCREMENT rather than plain rowid so a deleted id is never + * reused. Peers ask for messages by id, and a stale query must miss rather + * than hit someone else's mail. + */ +@Entity( + tableName = "mailbox_messages", + indices = [ + Index(value = ["destination"]), + Index(value = ["state"]), + Index(value = ["receivedAt"]) + ] +) +data class MailboxEntity( + @PrimaryKey(autoGenerate = true) + val id: Long = 0, + + /** Callsign that deposited the message */ + val originator: String, + + /** Callsign or @GROUP the message is for */ + val destination: String, + + val text: String, + + /** UTC milliseconds when we accepted the message */ + val receivedAt: Long, + + /** Originator's UTC milliseconds, when the deposit carried one */ + val originatedAt: Long? = null, + + /** Relay hops in CALL1>CALL2 notation, when the deposit came via relay */ + val relayPath: String? = null, + + /** SNR of the depositing transmission */ + val snr: Int? = null, + + /** Audio frequency offset in Hz of the depositing transmission */ + val offsetHz: Float? = null, + + /** One of [STATE_HELD], [STATE_DELIVERED], [STATE_EXPIRED] */ + val state: Int = STATE_HELD, + + /** UTC milliseconds of delivery, individual destinations only */ + val deliveredAt: Long? = null, + + /** [ORIGIN_DEPOSITED] or [ORIGIN_COMPOSED] */ + val origin: Int = ORIGIN_DEPOSITED +) { + companion object { + const val STATE_HELD = 0 + const val STATE_DELIVERED = 1 + const val STATE_EXPIRED = 2 + + /** Another station deposited it with us over the air */ + const val ORIGIN_DEPOSITED = 0 + + /** Composed here, waiting to be deposited at a relay */ + const val ORIGIN_COMPOSED = 1 + } +} diff --git a/android/app/src/main/java/com/js8call/example/data/MailboxGroupDeliveryEntity.kt b/android/app/src/main/java/com/js8call/example/data/MailboxGroupDeliveryEntity.kt new file mode 100644 index 000000000..e9da928ca --- /dev/null +++ b/android/app/src/main/java/com/js8call/example/data/MailboxGroupDeliveryEntity.kt @@ -0,0 +1,30 @@ +package com.js8call.example.data + +import androidx.room.Entity +import androidx.room.ForeignKey + +/** + * One retrieval of a group-addressed mailbox message by one station. + * + * A group message is never consumed by delivery. Any station may retrieve + * it, so delivery is recorded per callsign here, and the selection queries + * exclude only the messages a given station has already collected. This is + * desktop's inbox_group_recip_v1 table. + */ +@Entity( + tableName = "mailbox_group_delivery", + primaryKeys = ["msgId", "callsign"], + foreignKeys = [ + ForeignKey( + entity = MailboxEntity::class, + parentColumns = ["id"], + childColumns = ["msgId"], + onDelete = ForeignKey.CASCADE + ) + ] +) +data class MailboxGroupDeliveryEntity( + val msgId: Long, + val callsign: String, + val deliveredAt: Long +) diff --git a/android/app/src/main/java/com/js8call/example/data/MailboxRepository.kt b/android/app/src/main/java/com/js8call/example/data/MailboxRepository.kt new file mode 100644 index 000000000..2646d06ed --- /dev/null +++ b/android/app/src/main/java/com/js8call/example/data/MailboxRepository.kt @@ -0,0 +1,119 @@ +package com.js8call.example.data + +import android.content.Context +import androidx.lifecycle.LiveData +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +/** + * Repository for the store-and-forward mailbox: messages this station + * holds for other operators, and messages composed here awaiting deposit + * at a relay. + */ +class MailboxRepository(context: Context) { + + private val mailboxDao = MessageDatabase.getInstance(context).mailboxDao() + + fun getAll(): LiveData> = mailboxDao.getAll() + + fun getHeldCount(): LiveData = mailboxDao.getHeldCount() + + fun getHeldCountFor(callsign: String): LiveData = + mailboxDao.getHeldCountFor(callsign.trim().uppercase()) + + fun getDeliveryCounts(): LiveData> = mailboxDao.getDeliveryCounts() + + suspend fun store(message: MailboxEntity): Long = + withContext(Dispatchers.IO) { mailboxDao.insert(message) } + + /** The next held message for [callsign] with id above [afterId]. */ + suspend fun nextForCallsign(callsign: String, afterId: Long = 0): MailboxEntity? = + withContext(Dispatchers.IO) { + mailboxDao.nextForCallsign(callsign.trim().uppercase(), afterId) + } + + /** + * The next group message [callsign] has not collected, no older than + * [GROUP_RETRIEVAL_WINDOW_MS]. Any station may collect group mail; + * delivery is per callsign and the message is not consumed. + */ + suspend fun nextGroupForCallsign( + groupName: String, + callsign: String, + afterId: Long = 0, + now: Long = System.currentTimeMillis() + ): MailboxEntity? = withContext(Dispatchers.IO) { + mailboxDao.nextGroupForCallsign( + groupName.trim().uppercase(), + callsign.trim().uppercase(), + now - GROUP_RETRIEVAL_WINDOW_MS, + afterId + ) + } + + /** + * The next message [callsign] may collect, individual or group, with id + * above [afterId]. Drives QUERY MSGS and the NEXT MSG ID lookahead. + */ + suspend fun nextForRecipient( + callsign: String, + afterId: Long = 0, + now: Long = System.currentTimeMillis() + ): MailboxEntity? = withContext(Dispatchers.IO) { + mailboxDao.nextForRecipient( + callsign.trim().uppercase(), + now - GROUP_RETRIEVAL_WINDOW_MS, + afterId + ) + } + + /** + * The message behind a QUERY MSG {id}, or null when [callsign] may not + * collect it: unknown id, delivered already, someone else's mail, or a + * group message outside the retrieval window or collected before. + */ + suspend fun getEligible( + id: Long, + callsign: String, + now: Long = System.currentTimeMillis() + ): MailboxEntity? = withContext(Dispatchers.IO) { + val call = callsign.trim().uppercase() + val msg = mailboxDao.getById(id) ?: return@withContext null + if (msg.state != MailboxEntity.STATE_HELD) return@withContext null + when { + msg.destination == call -> msg + msg.destination.startsWith("@") && + msg.receivedAt >= now - GROUP_RETRIEVAL_WINDOW_MS && + mailboxDao.hasGroupDelivery(id, call) == 0 -> msg + else -> null + } + } + + suspend fun markDelivered(id: Long, at: Long = System.currentTimeMillis()) { + withContext(Dispatchers.IO) { mailboxDao.markDelivered(id, at) } + } + + suspend fun recordGroupDelivery(msgId: Long, callsign: String, at: Long = System.currentTimeMillis()) { + withContext(Dispatchers.IO) { + mailboxDao.recordGroupDelivery( + MailboxGroupDeliveryEntity(msgId, callsign.trim().uppercase(), at) + ) + } + } + + suspend fun deliveryCount(msgId: Long): Int = + withContext(Dispatchers.IO) { mailboxDao.deliveryCount(msgId) } + + suspend fun delete(id: Long) { + withContext(Dispatchers.IO) { mailboxDao.delete(id) } + } + + suspend fun deleteDelivered() { + withContext(Dispatchers.IO) { mailboxDao.deleteDelivered() } + } + + companion object { + /** Desktop offers group mail for 48 hours after storage. */ + const val GROUP_RETRIEVAL_WINDOW_MS = 48L * 60 * 60 * 1000 + } +} diff --git a/android/app/src/main/java/com/js8call/example/data/MessageDao.kt b/android/app/src/main/java/com/js8call/example/data/MessageDao.kt index 18973cbf5..d39659c4d 100644 --- a/android/app/src/main/java/com/js8call/example/data/MessageDao.kt +++ b/android/app/src/main/java/com/js8call/example/data/MessageDao.kt @@ -49,6 +49,35 @@ interface MessageDao { @Query("UPDATE messages SET isRead = 1 WHERE conversationId = :conversationId AND isRead = 0") suspend fun markConversationAsRead(conversationId: String) + /** + * An inbound ACK carries no message id, so it is taken as a receipt + * for the newest sent message in that conversation. + */ + @Query( + """ + UPDATE messages SET status = :acked WHERE id = ( + SELECT id FROM messages + WHERE conversationId = :conversationId AND direction = 1 AND status = :sent + ORDER BY timestamp DESC LIMIT 1 + ) + """ + ) + suspend fun markLatestSentAcked(conversationId: String, sent: Int, acked: Int) + + /** + * Prune stored group traffic the operator never subscribed to. + * Subscribed groups are passed in [keep] and left alone. + */ + @Query( + """ + DELETE FROM messages + WHERE conversationId LIKE '@%' + AND conversationId NOT IN (:keep) + AND timestamp < :cutoff + """ + ) + suspend fun deleteOldGroupMessages(cutoff: Long, keep: List) + @Query("UPDATE messages SET isRead = 1 WHERE id = :messageId") suspend fun markMessageAsRead(messageId: Long) diff --git a/android/app/src/main/java/com/js8call/example/data/MessageDatabase.kt b/android/app/src/main/java/com/js8call/example/data/MessageDatabase.kt index 49e555675..09c056536 100644 --- a/android/app/src/main/java/com/js8call/example/data/MessageDatabase.kt +++ b/android/app/src/main/java/com/js8call/example/data/MessageDatabase.kt @@ -4,19 +4,36 @@ import android.content.Context import androidx.room.Database import androidx.room.Room import androidx.room.RoomDatabase +import androidx.room.migration.Migration +import androidx.sqlite.db.SupportSQLiteDatabase /** - * Room database for storing JS8 messages. + * Room database for storing JS8 messages and heard-station contacts. */ @Database( - entities = [MessageEntity::class], - version = 2, - exportSchema = false + entities = [ + MessageEntity::class, + ContactEntity::class, + MailboxEntity::class, + MailboxGroupDeliveryEntity::class, + ConversationSettingsEntity::class, + LinkObservationEntity::class + ], + version = 7, + exportSchema = true ) abstract class MessageDatabase : RoomDatabase() { abstract fun messageDao(): MessageDao + abstract fun contactDao(): ContactDao + + abstract fun mailboxDao(): MailboxDao + + abstract fun conversationSettingsDao(): ConversationSettingsDao + + abstract fun linkObservationDao(): LinkObservationDao + companion object { private const val DATABASE_NAME = "js8_messages.db" @@ -29,13 +46,127 @@ abstract class MessageDatabase : RoomDatabase() { } } + // Adding the contacts table must not wipe stored messages. + private val MIGRATION_2_3 = object : Migration(2, 3) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL( + """ + CREATE TABLE IF NOT EXISTS `contacts` ( + `callsign` TEXT NOT NULL, + `lastHeard` INTEGER NOT NULL, + `snr` INTEGER, + `offset` REAL, + `grid` TEXT, + `info` TEXT, + `heardUs` INTEGER NOT NULL DEFAULT 0, + `starred` INTEGER NOT NULL DEFAULT 0, + `comment` TEXT, + PRIMARY KEY(`callsign`) + ) + """.trimIndent() + ) + db.execSQL("CREATE INDEX IF NOT EXISTS `index_contacts_lastHeard` ON `contacts` (`lastHeard`)") + } + } + + // The store-and-forward mailbox: messages held for other stations, + // with per-callsign delivery tracking for group destinations. + internal val MIGRATION_3_4 = object : Migration(3, 4) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL( + """ + CREATE TABLE IF NOT EXISTS `mailbox_messages` ( + `id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + `originator` TEXT NOT NULL, + `destination` TEXT NOT NULL, + `text` TEXT NOT NULL, + `receivedAt` INTEGER NOT NULL, + `originatedAt` INTEGER, + `relayPath` TEXT, + `snr` INTEGER, + `offsetHz` REAL, + `state` INTEGER NOT NULL, + `deliveredAt` INTEGER, + `origin` INTEGER NOT NULL + ) + """.trimIndent() + ) + db.execSQL("CREATE INDEX IF NOT EXISTS `index_mailbox_messages_destination` ON `mailbox_messages` (`destination`)") + db.execSQL("CREATE INDEX IF NOT EXISTS `index_mailbox_messages_state` ON `mailbox_messages` (`state`)") + db.execSQL("CREATE INDEX IF NOT EXISTS `index_mailbox_messages_receivedAt` ON `mailbox_messages` (`receivedAt`)") + db.execSQL( + """ + CREATE TABLE IF NOT EXISTS `mailbox_group_delivery` ( + `msgId` INTEGER NOT NULL, + `callsign` TEXT NOT NULL, + `deliveredAt` INTEGER NOT NULL, + PRIMARY KEY(`msgId`, `callsign`), + FOREIGN KEY(`msgId`) REFERENCES `mailbox_messages`(`id`) + ON UPDATE NO ACTION ON DELETE CASCADE + ) + """.trimIndent() + ) + } + } + + // Per-thread settings, currently just the relay path. Threads are + // derived from the messages table rather than stored, so a thread + // setting had nowhere to live before this. + internal val MIGRATION_4_5 = object : Migration(4, 5) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL( + """ + CREATE TABLE IF NOT EXISTS `conversation_settings` ( + `conversationId` TEXT NOT NULL, + `relayPath` TEXT, + PRIMARY KEY(`conversationId`) + ) + """.trimIndent() + ) + } + } + + // A station can be given a name, which is what the app shows in place + // of the callsign. Nullable, so every existing row stays valid. + internal val MIGRATION_5_6 = object : Migration(5, 6) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL("ALTER TABLE `contacts` ADD COLUMN `name` TEXT") + } + } + + // Link observations: append-only who-hears-whom evidence mined from + // decoded traffic, feeding the network map and relay recommendations. + internal val MIGRATION_6_7 = object : Migration(6, 7) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL( + """ + CREATE TABLE IF NOT EXISTS `link_observations` ( + `id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + `reporter` TEXT NOT NULL, + `heard` TEXT NOT NULL, + `snr` INTEGER, + `source` TEXT NOT NULL, + `dialFreqHz` INTEGER, + `observedAt` INTEGER NOT NULL + ) + """.trimIndent() + ) + db.execSQL("CREATE INDEX IF NOT EXISTS `index_link_observations_reporter` ON `link_observations` (`reporter`)") + db.execSQL("CREATE INDEX IF NOT EXISTS `index_link_observations_heard` ON `link_observations` (`heard`)") + db.execSQL("CREATE INDEX IF NOT EXISTS `index_link_observations_observedAt` ON `link_observations` (`observedAt`)") + } + } + private fun buildDatabase(context: Context): MessageDatabase { + // No destructive fallback: this database now holds traffic we + // promised a third party we would forward, so a migration gap + // must fail loudly instead of silently wiping it. return Room.databaseBuilder( context.applicationContext, MessageDatabase::class.java, DATABASE_NAME ) - .fallbackToDestructiveMigration() + .addMigrations(MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5, MIGRATION_5_6, MIGRATION_6_7) .build() } } diff --git a/android/app/src/main/java/com/js8call/example/data/MessageRepository.kt b/android/app/src/main/java/com/js8call/example/data/MessageRepository.kt index 8820d059a..d358b5442 100644 --- a/android/app/src/main/java/com/js8call/example/data/MessageRepository.kt +++ b/android/app/src/main/java/com/js8call/example/data/MessageRepository.kt @@ -12,6 +12,7 @@ class MessageRepository(context: Context) { private val database = MessageDatabase.getInstance(context) private val messageDao = database.messageDao() + private val settingsDao = database.conversationSettingsDao() // ========== Conversation List ========== @@ -68,7 +69,8 @@ class MessageRepository(context: Context) { text: String, snr: Int?, frequency: Float?, - relayPath: String? = null + relayPath: String? = null, + markRead: Boolean = false ): Long { val message = MessageEntity( conversationId = if (conversationId.startsWith("@")) conversationId.uppercase() else normalizeCallsign(conversationId), @@ -79,7 +81,7 @@ class MessageRepository(context: Context) { snr = snr, frequency = frequency, status = MessageEntity.STATUS_SENT, - isRead = false, + isRead = markRead, relayPath = relayPath ) return insertMessage(message) @@ -91,7 +93,8 @@ class MessageRepository(context: Context) { suspend fun insertOutgoingMessage( to: String, text: String, - status: Int = MessageEntity.STATUS_PENDING + status: Int = MessageEntity.STATUS_PENDING, + relayPath: String? = null ): Long { val message = MessageEntity( conversationId = normalizeCallsign(to), @@ -99,11 +102,35 @@ class MessageRepository(context: Context) { text = text, timestamp = System.currentTimeMillis(), status = status, - isRead = true // Outgoing messages are always "read" + isRead = true, // Outgoing messages are always "read" + relayPath = relayPath ) return insertMessage(message) } + /** + * Prune unsubscribed group traffic older than [cutoff]. It is stored + * speculatively so a later Join reveals history; it is not the + * operator's data and must not grow the database forever. + */ + suspend fun deleteOldGroupMessages(cutoff: Long, subscribed: List) { + withContext(Dispatchers.IO) { + // NOT IN () matches nothing in SQLite, so guarantee one element. + messageDao.deleteOldGroupMessages(cutoff, subscribed.ifEmpty { listOf("@") }) + } + } + + /** Take an inbound ACK as the receipt for the newest sent message. */ + suspend fun markLatestSentAcked(conversationId: String) { + withContext(Dispatchers.IO) { + messageDao.markLatestSentAcked( + normalizeCallsign(conversationId), + MessageEntity.STATUS_SENT, + MessageEntity.STATUS_ACKED + ) + } + } + suspend fun updateMessage(message: MessageEntity) { withContext(Dispatchers.IO) { messageDao.updateMessage(message) @@ -168,6 +195,30 @@ class MessageRepository(context: Context) { return messageDao.searchMessages(query) } + // ========== Thread settings ========== + + fun getRelayPath(callsign: String): LiveData { + return settingsDao.getRelayPath(normalizeCallsign(callsign)) + } + + suspend fun getRelayPathOnce(callsign: String): String? { + return withContext(Dispatchers.IO) { + settingsDao.getRelayPathOnce(normalizeCallsign(callsign)) + } + } + + /** An empty or blank path clears the row rather than storing "send direct". */ + suspend fun setRelayPath(callsign: String, path: String?) { + withContext(Dispatchers.IO) { + val id = normalizeCallsign(callsign) + if (path.isNullOrBlank()) { + settingsDao.delete(id) + } else { + settingsDao.upsert(ConversationSettingsEntity(id, path)) + } + } + } + // ========== Utility ========== private fun normalizeCallsign(callsign: String): String { diff --git a/android/app/src/main/java/com/js8call/example/model/DecodedMessage.kt b/android/app/src/main/java/com/js8call/example/model/DecodedMessage.kt index a2f7ca8f9..fa0725977 100644 --- a/android/app/src/main/java/com/js8call/example/model/DecodedMessage.kt +++ b/android/app/src/main/java/com/js8call/example/model/DecodedMessage.kt @@ -16,7 +16,9 @@ data class DecodedMessage( val mode: Int, // Suggested total drift (ms); used by "sync clock to this signal". val driftMs: Int = 0, - val timestamp: Long = System.currentTimeMillis() + val timestamp: Long = System.currentTimeMillis(), + // True for messages this station transmitted, shown in the decode list. + val outgoing: Boolean = false ) { /** * Check if this frame is marked as the first frame of a multipart message. diff --git a/android/app/src/main/java/com/js8call/example/model/EngineState.kt b/android/app/src/main/java/com/js8call/example/model/EngineState.kt index 4dbdee70c..85f6bbd5a 100644 --- a/android/app/src/main/java/com/js8call/example/model/EngineState.kt +++ b/android/app/src/main/java/com/js8call/example/model/EngineState.kt @@ -71,7 +71,13 @@ data class TransmitMessage( val text: String, val directed: String? = null, val priority: Int = 0, - val timestamp: Long = System.currentTimeMillis() + val timestamp: Long = System.currentTimeMillis(), + /** Row id in the message database, when this send belongs to a conversation. */ + val dbId: Long? = null, + /** Mailbox row this send delivers, marked once the transmission finishes. */ + val mailboxId: Long? = null, + /** Set for a group delivery: the collecting callsign to record. */ + val mailboxRecipient: String? = null ) /** diff --git a/android/app/src/main/java/com/js8call/example/service/JS8EngineService.kt b/android/app/src/main/java/com/js8call/example/service/JS8EngineService.kt index 653d618eb..d4530b514 100644 --- a/android/app/src/main/java/com/js8call/example/service/JS8EngineService.kt +++ b/android/app/src/main/java/com/js8call/example/service/JS8EngineService.kt @@ -33,15 +33,26 @@ import com.js8call.example.MainActivity import com.js8call.example.MessageLogWriter import com.js8call.example.R import com.js8call.example.BuildConfig +import com.js8call.example.data.LinkObservationEntity +import com.js8call.example.data.LinkRepository +import com.js8call.example.data.MailboxEntity +import com.js8call.example.data.MailboxRepository import com.js8call.example.network.PskReporterClient import com.js8call.example.util.CallsignValidator +import com.js8call.example.util.Js8Commands +import com.js8call.example.util.LinkEvidence +import com.js8call.example.util.RelayPath import com.js8call.example.util.TxMessageClassifier import java.util.Calendar import java.util.Locale import java.util.TimeZone -import java.util.concurrent.CountDownLatch import java.util.concurrent.LinkedBlockingDeque import java.util.concurrent.TimeUnit +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch internal fun assembleMsgPayload(parts: List): String = parts.joinToString(separator = "") @@ -55,6 +66,13 @@ class JS8EngineService : Service() { private var engine: JS8Engine? = null private var audioHelper: JS8AudioHelper? = null + + // Mailbox replies come off the decode path, but their DB reads must not + // block it. Main dispatcher so replies queue from the same thread the + // rest of the handlers run on. + private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.Main) + private val mailboxRepository by lazy { MailboxRepository(this) } + private val linkRepository by lazy { LinkRepository.getInstance(this) } private var rigCtlClient: RigCtlClient? = null private var rigCtlConnected: Boolean = false private var rigCtlErrorShown: Boolean = false @@ -129,6 +147,8 @@ class JS8EngineService : Service() { private var callsignWarningShown = false private var lastTxMessage: String = "" private var lastTxDirected: String = "" + private var lastTxFrameIndex: Int = 0 + private var lastTxFrameCount: Int = 0 private var lastTxSubmode: Int = SUBMODE_NORMAL private var lastTxFrequencyHz: Double = DEFAULT_AUDIO_FREQUENCY_HZ private var messageLogger: MessageLogWriter? = null @@ -153,6 +173,18 @@ class JS8EngineService : Service() { val millisecondsUntilAudio = activeEngine.txMillisecondsUntilAudio() txSessionActive = sessionActive txAudioActive = audioActive + if (sessionActive) { + val frameIndex = activeEngine.txFrameIndex() + val frameCount = activeEngine.txFrameCount() + if (frameIndex != lastTxFrameIndex || frameCount != lastTxFrameCount) { + lastTxFrameIndex = frameIndex + lastTxFrameCount = frameCount + broadcastTxProgress(frameIndex, frameCount) + } + } else { + lastTxFrameIndex = 0 + lastTxFrameCount = 0 + } if (!sessionActive) { txMonitorActive = false txMonitorWasAudioActive = false @@ -244,6 +276,8 @@ class JS8EngineService : Service() { override fun onCreate() { super.onCreate() createNotificationChannel() + pruneOtherGroupHistory() + pruneLinkObservations() usbSerialBridge = UsbSerialBridge(applicationContext) bluetoothSerialBridge = BluetoothSerialBridge(applicationContext) trusdxDirectSerial = TruSdxDirectSerial(applicationContext) @@ -336,6 +370,48 @@ class JS8EngineService : Service() { Log.i(TAG, "One-shot time sync armed; waiting for next decode") timeSyncOncePending = true } + ACTION_DEBUG_INJECT_TIMING -> { + // Debug builds only: drive the timing banner without waiting + // for a real decode drought and a lucky shifted-window hit. + if (BuildConfig.DEBUG) { + val kind = intent.getIntExtra(EXTRA_TIMING_KIND, TIMING_FOUND) + val driftMs = intent.getLongExtra(EXTRA_TIME_DRIFT_MS, -5500L) + val step = intent.getIntExtra(EXTRA_TIMING_STEP, 1) + val steps = intent.getIntExtra(EXTRA_TIMING_STEPS, 3) + val periodMs = intent.getIntExtra(EXTRA_TIMING_PERIOD_MS, 15_000) + Log.i(TAG, "Injected timing suggestion: kind=$kind drift=$driftMs") + mainHandler.post { + broadcastTimingSuggestion(kind, driftMs, step, steps, periodMs) + } + } + } + ACTION_DEBUG_INJECT_DECODE -> { + // Debug builds only: run a synthetic decode through the same + // path a real one takes. Protocol handling becomes testable on + // one emulator with no audio, including malformed frames and + // bad checksums no cooperating sender would produce. + if (BuildConfig.DEBUG) { + val text = intent.getStringExtra(EXTRA_TEXT) + if (!text.isNullOrBlank()) { + val snr = intent.getIntExtra(EXTRA_SNR, -10) + val freq = intent.getFloatExtra(EXTRA_FREQ, 1500f) + val type = intent.getIntExtra(EXTRA_TYPE, 0) + val submode = intent.getIntExtra(EXTRA_MODE, 0) + Log.i(TAG, "Injected decode: '$text' type=$type submode=$submode") + val cal = Calendar.getInstance(java.util.TimeZone.getTimeZone("UTC")) + val utc = cal.get(Calendar.HOUR_OF_DAY) * 10000 + + cal.get(Calendar.MINUTE) * 100 + cal.get(Calendar.SECOND) + mainHandler.post { + updateHeardCallsign(text) + recordLinkEvidence(text, snr) + broadcastDecode(utc, snr, 0f, freq, text, type, 1f, submode, 0) + handleRelayFrame(text, snr, submode, freq, type) + maybeHandleIncomingMessage(text, snr, freq, type, submode) + maybeHandleAutoReply(text, snr, submode) + } + } + } + } ACTION_SET_TIME_DRIFT -> { val driftMs = intent.getLongExtra(EXTRA_TIME_DRIFT_MS, 0L) Log.i(TAG, "Setting time drift to $driftMs ms") @@ -355,7 +431,8 @@ class JS8EngineService : Service() { override fun onDestroy() { super.onDestroy() Log.i(TAG, "Service destroyed") - + serviceScope.cancel() + val prefs = PreferenceManager.getDefaultSharedPreferences(this) prefs.unregisterOnSharedPreferenceChangeListener(preferenceChangeListener) heartbeatHandler.removeCallbacksAndMessages(null) @@ -399,10 +476,10 @@ class JS8EngineService : Service() { return NotificationCompat.Builder(this, CHANNEL_ID) .setContentTitle(getString(R.string.notification_title)) .setContentText(getString(R.string.notification_text)) - .setSmallIcon(android.R.drawable.ic_dialog_info) + .setSmallIcon(R.drawable.ic_graphic_eq) .setContentIntent(pendingIntent) .addAction( - android.R.drawable.ic_media_pause, + R.drawable.ic_pause, getString(R.string.notification_action_stop), stopPendingIntent ) @@ -474,9 +551,10 @@ class JS8EngineService : Service() { mainHandler.post { maybeApplyTimeSync(driftMs) updateHeardCallsign(text) + recordLinkEvidence(text, snr) broadcastDecode(utc, snr, dt, freq, text, type, quality, mode, driftMs) handleRelayFrame(text, snr, mode, freq, type) - maybeHandleIncomingMessage(text, snr, freq, type) + maybeHandleIncomingMessage(text, snr, freq, type, mode) maybeHandleAutoReply(text, snr, mode) maybeReportToPskReporter(utc, snr, freq, text) } @@ -510,6 +588,15 @@ class JS8EngineService : Service() { } } + override fun onTimingSuggestion( + kind: Int, driftMs: Int, step: Int, steps: Int, periodMs: Int + ) { + Log.i(TAG, "Timing suggestion: kind=$kind drift=$driftMs step=$step/$steps period=$periodMs") + mainHandler.post { + broadcastTimingSuggestion(kind, driftMs.toLong(), step, steps, periodMs) + } + } + override fun onError(message: String) { Log.e(TAG, "Engine error: $message") mainHandler.post { @@ -1379,6 +1466,16 @@ class JS8EngineService : Service() { stopTxMonitor() disableScoRouting() + // Stopping cancels the TX monitor, which is what would have + // reported the end of a send in flight. Without a terminal state + // the UI stays at Transmitting and the queue pump never sends again. + if (txSessionActive || txAudioActive) { + Log.i(TAG, "Engine stopped mid-transmission; failing the send in flight") + broadcastTxState(TX_STATE_FAILED) + } + txSessionActive = false + txAudioActive = false + txHandler.removeCallbacksAndMessages(null) synchronized(pttStateLock) { rigPttDesired = false @@ -1386,14 +1483,23 @@ class JS8EngineService : Service() { rigPttCommandPending = false rigPttCompletion = null } - if (isRigControlConnected()) { - if (!releaseRigPttForShutdown()) { - Log.e(TAG, "Unable to confirm PTT release during shutdown") - } - } - - // Disconnect rig control on background thread - val networkClientToDisconnect = rigCtlClient + // Rig teardown belongs on the TX handler, not here. With the radio + // gone a CAT command blocks for hamlib's full timeout, and every + // HamlibRigControl method shares one monitor, so close() waits + // behind it. That was nine seconds on the main thread. + val shutdownMode = rigControlMode + val shutdownTransport = rtsPttTransport + val shutdownHamlib = hamlibRigControl + val shutdownUsb = usbSerialBridge + val shutdownBluetooth = bluetoothSerialBridge + val shutdownTruSdx = trusdxSerialSession + val shutdownNetwork = rigCtlClient + val shouldReleasePtt = isRigControlConnected() + + hamlibRigControl = null + usbSerialBridge = null + bluetoothSerialBridge = null + trusdxSerialSession = null rigCtlClient = null rigCtlConnected = false rigCtlErrorShown = false @@ -1408,8 +1514,6 @@ class JS8EngineService : Service() { trusdxRxKeepaliveCount = 0L rtsPttTransport = null rigControlMode = "none" - hamlibRigControl?.close() - trusdxSerialSession?.stop() if (isTruSdxDiagnosticsEnabled() && (trusdxRxFrames > 0 || trusdxTxFrames > 0 || trusdxParserResyncs > 0 || trusdxTxDrops > 0 || trusdxRxUnderruns > 0 || trusdxRxFrameDrops > 0) ) { @@ -1418,15 +1522,36 @@ class JS8EngineService : Service() { "TruSDX diagnostics: rxFrames=$trusdxRxFrames rxSamples=$trusdxRxSamples rxFrameDrops=$trusdxRxFrameDrops rxSubmitDrops=$trusdxRxSubmitDrops rxUnderruns=$trusdxRxUnderruns txFrames=$trusdxTxFrames txSamples=$trusdxTxSamples txSilent=$trusdxTxSilentFrames txDrops=$trusdxTxDrops parserResyncs=$trusdxParserResyncs" ) } - usbSerialBridge?.unregisterNative() - usbSerialBridge?.close() - bluetoothSerialBridge?.close() - bluetoothSerialBridge?.unregisterNative() - if (networkClientToDisconnect != null) { - Thread { - networkClientToDisconnect.disconnect() - }.start() + txHandler.post { + if (shouldReleasePtt) { + // Captured references, not setRigPtt: the fields are + // cleared above so a restart cannot reuse a closing link. + val released = when (shutdownMode) { + "network" -> shutdownNetwork?.setPtt(false) == true + "hamlib_usb" -> shutdownHamlib?.setPtt(false) == true + "rts_ptt" -> when (shutdownTransport) { + SerialTransport.USB -> shutdownUsb?.setRts(false) == true + SerialTransport.BLUETOOTH -> shutdownBluetooth?.setRts(false) == true + else -> false + } + "trusdx_serial" -> shutdownTruSdx?.setPtt(false) == true + else -> false + } + if (released) { + synchronized(pttStateLock) { rigPttAsserted = false } + } else { + Log.e(TAG, "Unable to confirm PTT release during shutdown") + } + } + shutdownHamlib?.close() + shutdownTruSdx?.stop() + shutdownUsb?.unregisterNative() + shutdownUsb?.close() + shutdownBluetooth?.close() + shutdownBluetooth?.unregisterNative() + shutdownNetwork?.disconnect() + Log.i(TAG, "Rig control torn down") } pskReporterClient?.stop(flush = true) @@ -1449,6 +1574,47 @@ class JS8EngineService : Service() { putExtra(EXTRA_STATE, state) } LocalBroadcastManager.getInstance(this).sendBroadcast(intent) + + // The rig indicator on the Monitor strip needs the link state, and the + // connected flags are set in too many places to broadcast from each one. + // Poll while the engine runs instead, and report only on a change. + if (state == STATE_RUNNING || state == STATE_STARTING) { + startRigStatusPolling() + } else { + stopRigStatusPolling() + } + } + + private val rigStatusHandler = Handler(Looper.getMainLooper()) + private var rigStatusPolling = false + private var lastRigConnected: Boolean? = null + private val rigStatusRunnable = object : Runnable { + override fun run() { + if (!rigStatusPolling) return + broadcastRigStatus(isRigControlConnected()) + rigStatusHandler.postDelayed(this, RIG_STATUS_POLL_INTERVAL_MS) + } + } + + private fun startRigStatusPolling() { + if (rigStatusPolling) return + rigStatusPolling = true + rigStatusHandler.post(rigStatusRunnable) + } + + private fun stopRigStatusPolling() { + rigStatusPolling = false + rigStatusHandler.removeCallbacks(rigStatusRunnable) + broadcastRigStatus(false) + } + + private fun broadcastRigStatus(connected: Boolean) { + if (lastRigConnected == connected) return + lastRigConnected = connected + val intent = Intent(ACTION_RIG_STATUS).apply { + putExtra(EXTRA_RIG_CONNECTED, connected) + } + LocalBroadcastManager.getInstance(this).sendBroadcast(intent) } private fun broadcastDecode( @@ -1517,6 +1683,19 @@ class JS8EngineService : Service() { broadcastTimeDrift(driftMs) } + private fun broadcastTimingSuggestion( + kind: Int, driftMs: Long, step: Int, steps: Int, periodMs: Int + ) { + val intent = Intent(ACTION_TIMING_SUGGESTION).apply { + putExtra(EXTRA_TIMING_KIND, kind) + putExtra(EXTRA_TIME_DRIFT_MS, driftMs) + putExtra(EXTRA_TIMING_STEP, step) + putExtra(EXTRA_TIMING_STEPS, steps) + putExtra(EXTRA_TIMING_PERIOD_MS, periodMs) + } + LocalBroadcastManager.getInstance(this).sendBroadcast(intent) + } + private fun broadcastTimeDrift(driftMs: Long) { val intent = Intent(ACTION_TIME_DRIFT).apply { putExtra(EXTRA_TIME_DRIFT_MS, driftMs) @@ -2247,8 +2426,25 @@ class JS8EngineService : Service() { heartbeatHandler.postDelayed(heartbeatRunnable, waitMs) } - private fun getFrameDurationMs(): Long { - return when (getPreferredTxSubmode()) { + /** + * Run [block] just before [submode]'s next frame boundary. The modulator + * assumes it was asked at a boundary, as the desktop's TX loop does; + * mid-period it joins the frame in progress and sends only its tail. + * The block must pass [TX_BOUNDARY_DELAY_S] as txDelaySec. + */ + private fun scheduleAtNextTxBoundary(submode: Int, handler: Handler, block: () -> Unit) { + val period = framePeriodMs(submode) + val now = System.currentTimeMillis() + (engine?.timeDriftMs() ?: 0L) + val remaining = period - (((now % period) + period) % period) + if (remaining <= TX_BOUNDARY_LEAD_MS) { + block() + } else { + handler.postDelayed({ block() }, remaining - TX_BOUNDARY_LEAD_MS) + } + } + + private fun framePeriodMs(submode: Int): Long { + return when (submode) { SUBMODE_SLOW -> 30000L SUBMODE_NORMAL -> 15000L SUBMODE_FAST -> 10000L @@ -2257,6 +2453,8 @@ class JS8EngineService : Service() { } } + private fun getFrameDurationMs(): Long = framePeriodMs(getPreferredTxSubmode()) + /** * True for messages that belong in the heartbeat sub-band: heartbeats * themselves and heartbeat SNR acknowledgements. @@ -2328,25 +2526,32 @@ class JS8EngineService : Service() { val activeEngine = engine if (activeEngine != null) { val submode = getPreferredTxSubmode() - prepareEngineForTransmit(activeEngine) - val ok = activeEngine.transmitMessage( - text = payload, - myCall = callsign, - myGrid = grid, - selectedCall = "", // Broadcast-ish - submode = submode, - audioFrequencyHz = freq.toDouble(), - txDelaySec = 0.0, - forceIdentify = true, // Force ID to ensure callsign is sent - forceData = false - ) - - if (ok) { - updateLastTxMessage(payload, "", submode, freq.toDouble()) - broadcastTxState(TX_STATE_QUEUED) - startTxMonitor() - } else { - Log.e(TAG, "Failed to send heartbeat") + scheduleAtNextTxBoundary(submode, mainHandler) { + // Live query, not the monitor's cached flags: another + // deferred send may have started at this boundary. + if (engine !== activeEngine || activeEngine.isTransmitting()) { + return@scheduleAtNextTxBoundary + } + prepareEngineForTransmit(activeEngine) + val ok = activeEngine.transmitMessage( + text = payload, + myCall = callsign, + myGrid = grid, + selectedCall = "", // Broadcast-ish + submode = submode, + audioFrequencyHz = freq.toDouble(), + txDelaySec = TX_BOUNDARY_DELAY_S, + forceIdentify = true, // Force ID to ensure callsign is sent + forceData = false + ) + + if (ok) { + updateLastTxMessage(payload, "", submode, freq.toDouble()) + broadcastTxState(TX_STATE_QUEUED) + startTxMonitor() + } else { + Log.e(TAG, "Failed to send heartbeat") + } } } @@ -2419,28 +2624,33 @@ class JS8EngineService : Service() { "TX request: text='$payloadText', directed='${directed}', submode=$submode, freq=$audioFrequencyHz, delay=$txDelaySec, identify=$effectiveForceIdentify" ) - prepareEngineForTransmit(activeEngine) - val ok = activeEngine.transmitMessage( - text = payloadText, - myCall = callsign, - myGrid = grid, - selectedCall = directed, - submode = submode, - audioFrequencyHz = audioFrequencyHz, - txDelaySec = txDelaySec, - forceIdentify = effectiveForceIdentify, - forceData = forceData - ) + scheduleAtNextTxBoundary(submode, txHandler) { + if (engine !== activeEngine) return@scheduleAtNextTxBoundary + prepareEngineForTransmit(activeEngine) + val ok = activeEngine.transmitMessage( + text = payloadText, + myCall = callsign, + myGrid = grid, + selectedCall = directed, + submode = submode, + audioFrequencyHz = audioFrequencyHz, + txDelaySec = maxOf(txDelaySec, TX_BOUNDARY_DELAY_S), + forceIdentify = effectiveForceIdentify, + forceData = forceData + ) - if (ok) { - Log.i(TAG, "TX request accepted") - updateLastTxMessage(payloadText, directed, submode, audioFrequencyHz) - broadcastTxState(TX_STATE_QUEUED) - startTxMonitor() - } else { - Log.e(TAG, "TX request rejected") - broadcastError("Failed to start transmit") - broadcastTxState(TX_STATE_FAILED) + if (ok) { + Log.i(TAG, "TX request accepted") + recordMailQuery(payloadText, directed) + updateLastTxMessage(payloadText, directed, submode, audioFrequencyHz) + broadcastTxSent(buildTxMessage(payloadText, directed), audioFrequencyHz) + broadcastTxState(TX_STATE_QUEUED) + startTxMonitor() + } else { + Log.e(TAG, "TX request rejected") + broadcastError("Failed to start transmit") + broadcastTxState(TX_STATE_FAILED) + } } } @@ -2479,13 +2689,31 @@ class JS8EngineService : Service() { LocalBroadcastManager.getInstance(this).sendBroadcast(intent) } + private fun broadcastTxProgress(frameIndex: Int, frameCount: Int) { + val intent = Intent(ACTION_TX_PROGRESS).apply { + putExtra(EXTRA_TX_FRAME_INDEX, frameIndex) + putExtra(EXTRA_TX_FRAME_COUNT, frameCount) + } + LocalBroadcastManager.getInstance(this).sendBroadcast(intent) + } + + private fun broadcastTxSent(text: String, frequencyHz: Double) { + if (text.isBlank()) return + val intent = Intent(ACTION_TX_SENT).apply { + putExtra(EXTRA_TX_SENT_TEXT, text) + putExtra(EXTRA_TX_SENT_FREQ, frequencyHz.toFloat()) + } + LocalBroadcastManager.getInstance(this).sendBroadcast(intent) + } + private fun broadcastMessageReceived( from: String, text: String, snr: Int, freq: Float, relayPath: String?, - conversationId: String = from + conversationId: String = from, + silent: Boolean = false ) { val intent = Intent(ACTION_MESSAGE_RECEIVED).apply { putExtra(EXTRA_MESSAGE_FROM, from) @@ -2493,29 +2721,55 @@ class JS8EngineService : Service() { putExtra(EXTRA_MESSAGE_SNR, snr) putExtra(EXTRA_MESSAGE_FREQ, freq) putExtra(EXTRA_MESSAGE_CONVERSATION_ID, conversationId) + putExtra(EXTRA_MESSAGE_SILENT, silent) relayPath?.let { putExtra(EXTRA_MESSAGE_RELAY_PATH, it) } } LocalBroadcastManager.getInstance(this).sendBroadcast(intent) - - // Also show a notification - showMessageNotification(from, text) + + if (!silent) { + showMessageNotification(conversationId, from, text) + } } /** * Broadcast a request to queue a TX message. * The UI layer (TransmitViewModel) will handle adding it to the TX queue. */ - private fun broadcastQueueTx(text: String, directed: String?, priority: Int = 0) { + private fun broadcastQueueTx( + text: String, + directed: String?, + priority: Int = 0, + mailboxId: Long? = null, + mailboxRecipient: String? = null + ) { val intent = Intent(ACTION_QUEUE_TX).apply { putExtra(EXTRA_QUEUE_TX_TEXT, text) directed?.let { putExtra(EXTRA_QUEUE_TX_DIRECTED, it) } putExtra(EXTRA_QUEUE_TX_PRIORITY, priority) + mailboxId?.let { putExtra(EXTRA_QUEUE_TX_MAILBOX_ID, it) } + mailboxRecipient?.let { putExtra(EXTRA_QUEUE_TX_MAILBOX_RECIPIENT, it) } } LocalBroadcastManager.getInstance(this).sendBroadcast(intent) Log.d(TAG, "Broadcast queue TX: text='$text' directed=$directed priority=$priority") } - private fun showMessageNotification(from: String, text: String) { + /** An ACK for our traffic arrived: the UI sets the double check. */ + private fun broadcastMessageAcked(from: String) { + val intent = Intent(ACTION_MESSAGE_ACKED).apply { + putExtra(EXTRA_MESSAGE_FROM, from) + } + LocalBroadcastManager.getInstance(this).sendBroadcast(intent) + } + + /** A station we queried reported no mail waiting for us. */ + private fun broadcastMailboxEmpty(station: String) { + val intent = Intent(ACTION_MAILBOX_EMPTY).apply { + putExtra(EXTRA_MESSAGE_FROM, station) + } + LocalBroadcastManager.getInstance(this).sendBroadcast(intent) + } + + private fun showMessageNotification(conversationId: String, from: String, text: String) { val notificationManager = getSystemService(NotificationManager::class.java) // Create message notification channel if it doesn't exist @@ -2531,13 +2785,15 @@ class JS8EngineService : Service() { } // Create intent to open the app + // Tapping opens the thread the message landed in. For a group + // message that is the group, not a DM with whoever sent it. val intent = Intent(this, MainActivity::class.java).apply { flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP putExtra("open_messages", true) - putExtra("callsign", from) + putExtra("callsign", conversationId) } val pendingIntent = PendingIntent.getActivity( - this, from.hashCode(), intent, + this, conversationId.hashCode(), intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE ) @@ -2727,35 +2983,6 @@ class JS8EngineService : Service() { broadcastTxState(TX_STATE_FAILED) } - private fun releaseRigPttForShutdown(): Boolean { - if (Looper.myLooper() == txHandler.looper) { - val released = setRigPtt(false) - if (released) rigPttAsserted = false - return released - } - - val completed = CountDownLatch(1) - var released = false - txHandler.post { - try { - released = setRigPtt(false) - if (released) { - synchronized(pttStateLock) { - rigPttAsserted = false - } - } - } finally { - completed.countDown() - } - } - completed.await() - if (!released) { - released = setRigPtt(false) - if (released) rigPttAsserted = false - } - return released - } - /** * Handle incoming MSG commands - always runs regardless of autoreply setting. * This ensures messages are saved to inbox even if auto-ACK is disabled. @@ -2766,7 +2993,7 @@ class JS8EngineService : Service() { * FROM: TO MSG (multi-frame: command frame) * payload... (multi-frame: data frames follow) */ - private fun maybeHandleIncomingMessage(text: String, snr: Int, freq: Float, type: Int) { + private fun maybeHandleIncomingMessage(text: String, snr: Int, freq: Float, type: Int, submode: Int) { val callsign = getConfiguredCallsign() Log.d(TAG, "maybeHandleIncomingMessage: text='$text' type=$type callsign=$callsign") if (callsign == null) { @@ -2779,13 +3006,57 @@ class JS8EngineService : Service() { // Try parsing as a directed command (MSG header frame) val directed = parseDirectedCommand(text) - + + // MSG TO: is a deposit into our mailbox for a third party, and must + // be caught before the MSG branch below would read it as mail for us. + if (directed != null && directed.command.uppercase() == Js8Commands.CMD_MSG_TO) { + handleMailboxDeposit(directed, snr, freq, type, submode, now) + return + } + + // Replies to our own traffic. + if (directed != null && isSelfCallsign(callsign, directed.to) && + !isSelfCallsign(callsign, directed.from) + ) { + when (directed.command.uppercase()) { + Js8Commands.CMD_ACK -> { + // Receipt for a message we sent: light the double check. + Log.i(TAG, "ACK received from ${directed.from}") + broadcastMessageAcked(directed.from.trim().uppercase()) + return + } + Js8Commands.CMD_YES -> { + // YES MSG ID {n}: mail is waiting for us; go collect it. + val m = Regex("^MSG ID\\s+(\\d+)", RegexOption.IGNORE_CASE) + .find(directed.payload.trim()) + if (m != null && expectingMailFrom(directed.from)) { + fetchMailboxMessage( + callsign, + directed.from.trim().uppercase(), + m.groupValues[1].toLong() + ) + return + } + } + Js8Commands.CMD_NO -> { + if (expectingMailFrom(directed.from)) { + Log.i(TAG, "No mail waiting at ${directed.from}") + broadcastMailboxEmpty(directed.from.trim().uppercase()) + return + } + } + } + } + if (directed != null && (directed.command.uppercase() == "MSG" || directed.command.uppercase().startsWith("MSG"))) { // This is a MSG command frame val isForMe = isSelfCallsign(callsign, directed.to) val isForMyGroup = isSubscribedGroup(directed.to) - - if (!isForMe && !isForMyGroup) { + // Unsubscribed group traffic is stored too, silently, so the + // history is already there if the operator joins the group later. + val isOtherGroup = !isForMyGroup && isStorableGroup(directed.to) + + if (!isForMe && !isForMyGroup && !isOtherGroup) { Log.d(TAG, "maybeHandleIncomingMessage: MSG not for me ($callsign) or my groups, skipping") return } @@ -2810,20 +3081,14 @@ class JS8EngineService : Service() { // If this is the last frame and we have payload, deliver immediately if (isLastFrame(type) && initialPayload.isNotBlank()) { - val conversationId = if (isForMyGroup) directed.to else directed.from + val conversationId = if (isForMyGroup || isOtherGroup) directed.to else directed.from // Strip checksum (3 uppercase alphanumeric chars at end, preceded by space) val cleanPayload = initialPayload.trim() .replace(Regex("\\s*[▪■]+\\s*$"), "") .replace(Regex("\\s+[A-Z0-9]{3}$"), "") .trim() Log.i(TAG, "MSG received (single frame): from=${directed.from} to=${directed.to} text='$cleanPayload' conversationId=$conversationId") - broadcastMessageReceived(directed.from, cleanPayload, snr, freq, null, conversationId) - - // Queue auto-ACK now that full message is received (if autoreply enabled) - if (isAutoreplyEnabled()) { - Log.i(TAG, "Auto ACK for MSG from=${directed.from}") - broadcastQueueTx("$callsign: ${directed.from} ACK", null, priority = 2) - } + deliverIncomingMsg(callsign, directed.from.trim().uppercase(), cleanPayload, snr, freq, conversationId) return } @@ -2835,6 +3100,11 @@ class JS8EngineService : Service() { snr = snr, frequency = freq, lastUpdated = now, + // Four frame periods of the submode the command arrived in. + // A flat 60 seconds expires a Slow-mode message between its + // own 30-second frames if one decode runs late or one frame + // is missed. + timeoutMs = maxOf(MSG_BUFFER_TIMEOUT_MS, 4 * framePeriodMs(submode)), parts = if (initialPayload.isNotBlank()) mutableListOf(initialPayload) else mutableListOf() ) synchronized(msgLock) { @@ -2882,17 +3152,30 @@ class JS8EngineService : Service() { val snr: Int, val frequency: Float, var lastUpdated: Long, + val timeoutMs: Long, + /** MSG for direct mail, MSG TO: for a mailbox deposit */ + val command: String = Js8Commands.CMD_MSG, val parts: MutableList = mutableListOf() ) private val msgBuffers = mutableMapOf() private val msgLock = Any() + // Floor for the per-buffer timeout; slow submodes get four frame periods. private val MSG_BUFFER_TIMEOUT_MS = 60_000L + + // One mailbox reply per peer inside this window. + private val MAILBOX_REPLY_WINDOW_MS = 60_000L + + // How long a QUERY MSGS keeps mailbox replies from that station expected. + private val MAIL_RETRIEVAL_WINDOW_MS = 10 * 60_000L + + // Retention for stored traffic of groups the operator is not in. + private val OTHER_GROUP_RETENTION_MS = 30L * 24 * 60 * 60 * 1000 private fun cleanupMsgBuffers(now: Long) { synchronized(msgLock) { msgBuffers.entries.removeIf { (_, buffer) -> - now - buffer.lastUpdated > MSG_BUFFER_TIMEOUT_MS + now - buffer.lastUpdated > buffer.timeoutMs } } } @@ -2909,7 +3192,113 @@ class JS8EngineService : Service() { return null } + /** + * A MSG TO: command frame: another station asks us to hold mail for a + * third party. The destination and text follow in data frames, so this + * opens a buffer; [completeMailboxDeposit] runs on the last frame. + */ + private fun handleMailboxDeposit( + directed: DirectedCommand, snr: Int, freq: Float, type: Int, submode: Int, now: Long + ) { + val callsign = getConfiguredCallsign() ?: return + if (!isSelfCallsign(callsign, directed.to)) return + if (isSelfCallsign(callsign, directed.from)) return + if (!isMailboxEnabled()) { + Log.i(TAG, "Mailbox deposit from=${directed.from} ignored: mailbox disabled") + return + } + + if (isLastFrame(type)) { + if (directed.payload.isNotBlank()) { + completeMailboxDeposit(directed.from.trim().uppercase(), directed.payload, snr, freq) + } + return + } + + val key = findMatchingMsgBufferKey(freq) ?: Math.round(freq) + val buffer = MsgBuffer( + from = directed.from.trim().uppercase(), + to = directed.to.trim().uppercase(), + snr = snr, + frequency = freq, + lastUpdated = now, + timeoutMs = maxOf(MSG_BUFFER_TIMEOUT_MS, 4 * framePeriodMs(submode)), + command = Js8Commands.CMD_MSG_TO, + parts = if (directed.payload.isNotBlank()) mutableListOf(directed.payload) else mutableListOf() + ) + synchronized(msgLock) { + msgBuffers[key] = buffer + } + Log.d(TAG, "handleMailboxDeposit: buffered MSG TO: command, waiting for data frames") + } + + /** + * The reassembled text of a deposit: "DEST message CHK". Validates the + * checksum, stores the message, and confirms with ACK. + */ + private fun completeMailboxDeposit( + from: String, + payload: String, + snr: Int, + freq: Float, + originatorOverride: String? = null, + replyPath: String? = null + ) { + val callsign = getConfiguredCallsign() ?: return + var text = payload.trim().replace(Regex("\\s*[▪■]+\\s*$"), "").trim() + + // The last token is a 3-character checksum over everything before it. + val (checksumOk, checked) = validateRelayChecksum(text) + if (!checksumOk) { + Log.w(TAG, "Mailbox deposit checksum mismatch from=$from text='$text'") + if (isMailboxStrictChecksum()) return + } + text = if (checksumOk) checked.trim() else stripOptionalRelayChecksum(text).trim() + + // First token is who the mail is for; the rest is the message. + val dest = text.substringBefore(' ').trim().uppercase() + val body = text.substringAfter(' ', "").trim() + if (dest.isBlank() || body.isBlank()) { + Log.w(TAG, "Mailbox deposit from=$from missing destination or text, dropping") + return + } + + // A from of A>B means A originated the message and B relayed it here. + // A deposit that arrived over a relay path has both already resolved + // by the caller, whose path runs the other way round. + val hops = from.split(">").map { it.trim() }.filter { it.isNotEmpty() } + val originator = originatorOverride ?: hops.firstOrNull() ?: from + val relayPath = replyPath ?: if (hops.size > 1) from else null + val ackTarget = replyPath ?: from + + serviceScope.launch { + val id = mailboxRepository.store( + MailboxEntity( + originator = originator, + destination = dest, + text = body, + receivedAt = System.currentTimeMillis(), + relayPath = relayPath, + snr = snr, + offsetHz = freq + ) + ) + Log.i(TAG, "Mailbox deposit stored: id=$id from=$originator dest=$dest text='$body'") + // Accepted mail is confirmed no matter what autoreply says. + // Taking the message and refusing to say so is the worst of both. + val ack = if (replyPath != null) "$ackTarget ACK" else "$callsign: $ackTarget ACK" + broadcastQueueTx(ack, null, priority = 2) + } + } + private fun processMsgBuffer(buffer: MsgBuffer, myCallsign: String) { + if (buffer.command == Js8Commands.CMD_MSG_TO) { + val assembled = assembleMsgPayload(buffer.parts).trim() + if (assembled.isNotBlank()) { + completeMailboxDeposit(buffer.from, assembled, buffer.snr, buffer.frequency) + } + return + } // Data frames split at arbitrary byte boundaries, not word boundaries. val fullText = assembleMsgPayload(buffer.parts).trim() // Remove end-of-message marker if present @@ -2924,18 +3313,74 @@ class JS8EngineService : Service() { return } - val isForMyGroup = isSubscribedGroup(buffer.to) - val conversationId = if (isForMyGroup) buffer.to else buffer.from - + val groupConversation = buffer.to.startsWith("@") + val conversationId = if (groupConversation) buffer.to else buffer.from + Log.i(TAG, "MSG received (multi-frame): from=${buffer.from} to=${buffer.to} text='$cleanText' conversationId=$conversationId") - broadcastMessageReceived(buffer.from, cleanText, buffer.snr, buffer.frequency, null, conversationId) - - // Queue auto-ACK now that full message is received (if autoreply enabled) - if (isAutoreplyEnabled()) { - Log.i(TAG, "Auto ACK for MSG from=${buffer.from}") - broadcastQueueTx("$myCallsign: ${buffer.from} ACK", null, priority = 2) + deliverIncomingMsg(myCallsign, buffer.from, cleanText, buffer.snr, buffer.frequency, conversationId) + } + + /** + * A complete MSG for us. Collected mailbox mail arrives here too, as + * "{text} FROM {originator}" with "NEXT MSG ID {n}" appended while the + * mailbox holds more: it threads under the originator with the mailbox + * station as the relay hop, and the next message is fetched. + */ + private fun deliverIncomingMsg( + callsign: String, + from: String, + cleanText: String, + snr: Int, + freq: Float, + conversationId: String + ) { + val delivered = parseDeliveredMail(from, cleanText) + if (delivered != null) { + Log.i( + TAG, + "Mailbox mail collected: originator=${delivered.originator} via=$from next=${delivered.nextId}" + ) + broadcastMessageReceived( + delivered.originator, delivered.text, snr, freq, from, delivered.originator + ) + delivered.nextId?.let { fetchMailboxMessage(callsign, from, it) } + } else { + // Traffic for a group we are not in is stored without sound: + // no notification, and it arrives already read. + val silent = conversationId.startsWith("@") && !isSubscribedGroup(conversationId) + broadcastMessageReceived(from, cleanText, snr, freq, null, conversationId, silent) + } + + // Queue auto-ACK now that full message is received (if autoreply + // enabled). Never for a group: every subscriber ACKing at once + // would pile the band with confirmations. + if (isAutoreplyEnabled() && !conversationId.startsWith("@")) { + Log.i(TAG, "Auto ACK for MSG from=$from") + broadcastQueueTx("$callsign: $from ACK", null, priority = 2) } } + + private data class DeliveredMail(val text: String, val originator: String, val nextId: Long?) + + /** + * Parse "{text} FROM {originator}[ NEXT MSG ID {n}]", but only when we + * asked [sender] for mail. Ordinary messages can end in FROM too. + */ + private fun parseDeliveredMail(sender: String, payload: String): DeliveredMail? { + if (!expectingMailFrom(sender)) return null + var text = payload.trim() + var nextId: Long? = null + Regex("\\sNEXT MSG ID\\s+(\\d+)$", RegexOption.IGNORE_CASE).find(text)?.let { + nextId = it.groupValues[1].toLong() + text = text.removeRange(it.range).trim() + } + val from = Regex("\\sFROM\\s+([A-Za-z0-9/]+)$", RegexOption.IGNORE_CASE).find(text) + ?: return null + val originator = from.groupValues[1].uppercase() + text = text.removeRange(from.range).trim() + if (text.isBlank()) return null + return DeliveredMail(text, originator, nextId) + } private fun isDataFrame(type: Int): Boolean = (type and 0x4) != 0 @@ -2947,6 +3392,29 @@ class JS8EngineService : Service() { return groups.contains(target.uppercase()) } + /** Drop unsubscribed group traffic older than 30 days. */ + private fun pruneOtherGroupHistory() { + val prefs = PreferenceManager.getDefaultSharedPreferences(this) + val subscribed = (prefs.getString("my_groups", "") ?: "") + .split(",").map { it.trim().uppercase() }.filter { it.isNotEmpty() } + val cutoff = System.currentTimeMillis() - OTHER_GROUP_RETENTION_MS + serviceScope.launch { + com.js8call.example.data.MessageRepository(this@JS8EngineService) + .deleteOldGroupMessages(cutoff, subscribed) + } + } + + /** + * Groups whose traffic is stored even without a subscription, so the + * history exists when the operator joins later. @ALLCALL and @HB are + * broadcast addresses, not communities; storing them would bury the + * real groups under every heartbeat on the band. + */ + private fun isStorableGroup(target: String): Boolean { + if (!target.startsWith("@")) return false + return target.uppercase() !in setOf("@ALLCALL", "@HB") + } + private fun maybeHandleAutoReply(text: String, snr: Int, mode: Int) { if (!isAutoreplyEnabled()) return val prefs = PreferenceManager.getDefaultSharedPreferences(this) @@ -2970,6 +3438,24 @@ class JS8EngineService : Service() { } val directed = parseDirectedCommand(text) ?: return + + // Mailbox queries route before shouldReplyToDirected because a group + // query (A: @ALLCALL QUERY MSGS?) is legitimate and that check + // rejects every @ destination. + val mailboxCmd = directed.command.uppercase() + if (mailboxCmd == Js8Commands.CMD_QUERY_MSGS || mailboxCmd == "QUERY MSGS?") { + handleQueryMsgs(callsign, directed) + return + } + if (mailboxCmd == Js8Commands.CMD_QUERY) { + val idMatch = Regex("^MSG\\s+(\\d+)$", RegexOption.IGNORE_CASE) + .matchEntire(directed.payload.trim()) + if (idMatch != null) { + handleQueryMsg(callsign, directed, idMatch.groupValues[1].toLong()) + return + } + } + if (!shouldReplyToDirected(callsign, directed)) return val cmdUpper = directed.command.uppercase() when { @@ -3017,8 +3503,169 @@ class JS8EngineService : Service() { } } + /** + * A: ME QUERY MSGS — does our mailbox hold anything for A? Reply + * YES MSG ID {id} or NO. A group-addressed query gets YES or silence: + * every idle station replying NO to an @ALLCALL sweep floods the band. + */ + private fun handleQueryMsgs(callsign: String, directed: DirectedCommand) { + val requester = directed.from.trim().uppercase() + val groupAddressed = directed.to.startsWith("@") + if (!groupAddressed && !isSelfCallsign(callsign, directed.to)) return + serveQueryMsgs(callsign, requester, groupAddressed, replyPath = null) + } + + /** + * A reply goes straight back to the asker, or down [replyPath] when the + * question came over a relay. The path already reads nearest hop first, + * which is the order a reply needs. + */ + private fun serveQueryMsgs( + callsign: String, + requester: String, + groupAddressed: Boolean, + replyPath: String? + ) { + if (!isMailboxEnabled()) return + if (isSelfCallsign(callsign, requester)) return + if (!mailboxReplyAllowed(requester, "QUERY MSGS")) return + val prefix = replyPath ?: "$callsign: $requester" + serviceScope.launch { + val next = mailboxRepository.nextForRecipient(requester) + if (next != null) { + Log.i(TAG, "Mailbox query from=$requester: offering MSG ID ${next.id}") + broadcastQueueTx("$prefix YES MSG ID ${next.id}", null, priority = 1) + } else if (!groupAddressed) { + Log.i(TAG, "Mailbox query from=$requester: nothing held") + broadcastQueueTx("$prefix NO", null, priority = 1) + } + } + } + + /** + * A: ME QUERY MSG {id} — deliver it. The reply threads under the + * originator on A's side: MSG {text} FROM {originator}, plus + * NEXT MSG ID {n} when more mail waits. Delivery is marked when the + * transmission finishes, not here; a failed send must stay held. + */ + private fun handleQueryMsg(callsign: String, directed: DirectedCommand, msgId: Long) { + val requester = directed.from.trim().uppercase() + if (!directed.to.startsWith("@") && !isSelfCallsign(callsign, directed.to)) return + serveQueryMsg(callsign, requester, msgId, replyPath = null) + } + + private fun serveQueryMsg( + callsign: String, + requester: String, + msgId: Long, + replyPath: String? + ) { + if (!isMailboxEnabled()) return + if (isSelfCallsign(callsign, requester)) return + if (!mailboxReplyAllowed(requester, "QUERY MSG $msgId")) return + val prefix = replyPath ?: "$callsign: $requester" + serviceScope.launch { + val msg = mailboxRepository.getEligible(msgId, requester) + if (msg == null) { + Log.i(TAG, "Mailbox retrieve from=$requester id=$msgId: not eligible") + return@launch + } + val lookahead = mailboxRepository.nextForRecipient(requester, afterId = msg.id) + val reply = buildString { + append("$prefix MSG ${msg.text} FROM ${msg.originator}") + if (lookahead != null) append(" NEXT MSG ID ${lookahead.id}") + } + Log.i(TAG, "Mailbox retrieve from=$requester id=${msg.id}, lookahead=${lookahead?.id}") + broadcastQueueTx( + reply, null, priority = 1, + mailboxId = msg.id, + // A group message is recorded per collector; individual mail + // is marked delivered outright. + mailboxRecipient = if (msg.destination.startsWith("@")) requester else null + ) + } + } + + // Stations we asked for mail. A "MSG {text} FROM {call}" reply is only + // read as mailbox attribution when we actually asked the sender: + // ordinary text can end the same way ("GREETINGS FROM W1AW"), and + // misreading it would thread the message under the wrong callsign. + private val pendingMailRetrievals = mutableMapOf() + + private fun recordMailQuery(text: String, directed: String) { + val trimmed = text.trim().uppercase() + + // A relay carries the destination in the payload rather than the + // directed field, so the station we are asking is the last callsign + // of the ">" chain, not the first. Reading the first would arm the + // window against the nearest hop and the reply would be ignored. + val relayMatch = Regex("^((?:[A-Z0-9/]+>)+)([A-Z0-9/]+)\\s+QUERY MSG").find(trimmed) + val peer = when { + relayMatch != null -> { + mailRetrievalHops = relayMatch.groupValues[1].count { it == '>' } + relayMatch.groupValues[2] + } + directed.isNotBlank() -> { + if (!trimmed.startsWith("QUERY MSG")) return + mailRetrievalHops = 0 + directed.trim().uppercase() + } + else -> { + val m = Regex("^\\S+:\\s+(\\S+)\\s+QUERY MSG").find(trimmed) ?: return + mailRetrievalHops = 0 + m.groupValues[1] + } + } + if (peer.startsWith("@")) return + pendingMailRetrievals[peer] = System.currentTimeMillis() + Log.d(TAG, "Expecting mailbox replies from $peer (hops=$mailRetrievalHops)") + } + + /** + * Hops on the path of the last query. Every hop is a full retransmission, + * so a two-hop exchange in a slow submode will not finish inside the + * direct window. + */ + private var mailRetrievalHops = 0 + + private fun expectingMailFrom(peer: String): Boolean { + val asked = pendingMailRetrievals[peer.trim().uppercase()] ?: return false + val window = MAIL_RETRIEVAL_WINDOW_MS * (mailRetrievalHops + 1) + return System.currentTimeMillis() - asked < window + } + + /** Ask [station] for held message [id], guarding against reply loops. */ + private fun fetchMailboxMessage(callsign: String, station: String, id: Long) { + if (!mailboxReplyAllowed(station, "FETCH $id")) return + pendingMailRetrievals[station.trim().uppercase()] = System.currentTimeMillis() + Log.i(TAG, "Fetching mailbox message $id from $station") + broadcastQueueTx("$callsign: $station QUERY MSG $id", null, priority = 1) + } + + // One answer per peer per question inside the window. Keyed on the + // question, not the peer alone: the normal retrieval flow is QUERY MSGS, + // then QUERY MSG {id} right after our YES, and a per-peer limit would + // suppress the very retrieval the YES invited. What this stops is a + // stuck station asking the same thing over and over. + private val mailboxReplyTimes = mutableMapOf() + + private fun mailboxReplyAllowed(peer: String, query: String): Boolean { + val now = System.currentTimeMillis() + val key = "$peer $query" + val last = mailboxReplyTimes[key] + if (last != null && now - last < MAILBOX_REPLY_WINDOW_MS) { + Log.d(TAG, "Mailbox reply to $peer for '$query' suppressed: rate limit") + return false + } + mailboxReplyTimes[key] = now + return true + } + + // Relayed traffic addressed to us is always taken in. The relay_enabled + // preference governs carrying other people's traffic onward, which is + // the choice an operator actually makes, and it is checked at the point + // of forwarding in processRelayBuffer. private fun handleRelayFrame(text: String, snr: Int, mode: Int, freq: Float, type: Int) { - if (!isRelayEnabled()) return val callsign = getConfiguredCallsign() ?: return val now = System.currentTimeMillis() cleanupRelayBuffers(now) @@ -3110,25 +3757,33 @@ class JS8EngineService : Service() { } var to = toToken - var command: String - var payloadStart = index + 1 + val command: String + val payload: String if (toToken.endsWith(">")) { to = toToken.trimEnd('>') command = ">" + payload = tokens.drop(index + 1).joinToString(" ") } else { if (index + 1 >= tokens.size) return null - command = tokens[index + 1] - payloadStart = index + 2 + // Match the command table rather than taking one token, so the + // two-word names survive: MSG TO: and QUERY MSGS would otherwise + // split at the space and arrive as MSG and QUERY. + val remainder = tokens.drop(index + 1).joinToString(" ") + val match = Js8Commands.matchAt(remainder) + if (match != null) { + command = match.command + payload = match.payload + } else { + // Unknown text keeps the old single-token shape, so free-text + // frames reach callers exactly as they did before. + command = tokens[index + 1] + payload = tokens.drop(index + 2).joinToString(" ") + } } if (to.isBlank() || command.isBlank()) return null if (from.isBlank() && command != ">") return null - val payload = if (payloadStart < tokens.size) { - tokens.subList(payloadStart, tokens.size).joinToString(" ") - } else { - "" - } return DirectedCommand(from, to, command, payload) } @@ -3172,6 +3827,19 @@ class JS8EngineService : Service() { return prefs.getBoolean(PREF_RELAY_ENABLED, false) } + // Off by default and separate from autoreply: holding and forwarding + // third-party traffic is a regulatory question in some jurisdictions, + // so an operator opts into it deliberately. + private fun isMailboxEnabled(): Boolean { + val prefs = PreferenceManager.getDefaultSharedPreferences(this) + return prefs.getBoolean(PREF_MAILBOX_ENABLED, false) + } + + private fun isMailboxStrictChecksum(): Boolean { + val prefs = PreferenceManager.getDefaultSharedPreferences(this) + return prefs.getBoolean(PREF_MAILBOX_STRICT_CHECKSUM, false) + } + private fun getPreferredTxSubmode(): Int { val prefs = PreferenceManager.getDefaultSharedPreferences(this) val submode = prefs.getInt(PREF_TX_SUBMODE, SUBMODE_NORMAL) @@ -3382,8 +4050,18 @@ class JS8EngineService : Service() { if (payload.isBlank()) return + // The *DE* trail is link evidence regardless of whether we forward, + // deliver, or drop this traffic. Observing is passive. + recordRelayLinkEvidence(buffer.from, payload) + val forwardPayload = buildRelayForwardPayload(payload) if (forwardPayload != null) { + // Carrying somebody else's traffic is the part an operator opts + // into. Mail addressed to us is delivered below either way. + if (!isRelayEnabled()) { + Log.i(TAG, "Relay forwarding disabled, dropping transit traffic") + return + } val forwardText = if (buffer.from.isNotBlank()) { "$forwardPayload *DE* ${buffer.from}" } else { @@ -3393,18 +4071,172 @@ class JS8EngineService : Service() { return } - val trimmed = payload.trimStart() - if (trimmed.startsWith("ACK", ignoreCase = true)) { + val relayPath = parseRelayPathCallsigns(buffer.from, payload).joinToString(">") + if (relayPath.isBlank()) return + + handleRelayedArrival(payload, relayPath, buffer.snr, buffer.frequency, buffer.submode) + } + + /** + * A relayed message that reached its destination, which is us. The path + * runs nearest hop first, so the station we are really talking to is at + * the far end of it. Everything here answers back down the same path. + */ + private fun handleRelayedArrival( + payload: String, + relayPath: String, + snr: Int, + freq: Float, + submode: Int + ) { + val callsign = getConfiguredCallsign() ?: return + val originator = RelayPath.originatorOfReturnPath(relayPath) ?: return + + // Every hop appended itself, and that trail is what the return path + // was built from. It is not part of what the operator wrote, so it + // comes off before anything reads the text as a command or a message. + val trimmed = stripRelayAttribution(payload) + if (trimmed.isBlank()) return + + if (trimmed.equals("ACK", ignoreCase = true) || + trimmed.startsWith("ACK ", ignoreCase = true) + ) { + Log.i(TAG, "Relayed ACK from $originator via $relayPath") + broadcastMessageAcked(originator) return } - val relayPath = parseRelayPathCallsigns(buffer.from, payload).joinToString(">") - if (relayPath.isBlank()) return + if (handleRelayedMailboxReply(originator, relayPath, trimmed, snr, freq)) return + if (handleRelayedMailboxQuery(callsign, originator, relayPath, trimmed, snr, freq)) return + if (maybeHandleRelayedAutoreply(trimmed, relayPath, snr, submode)) return - val handled = maybeHandleRelayedAutoreply(payload, relayPath, buffer.snr, buffer.submode) - if (!handled) { - sendRelayMessage("$relayPath ACK", buffer.submode) + // A question we have no answer for stays a question. Storing "SNR?" + // as a chat message would just be noise in the thread. + if (trimmed.substringBefore(' ').endsWith("?")) { + Log.d(TAG, "Relayed query '$trimmed' from $originator went unanswered") + return } + + // Not a command, so it is a message. Thread it under the station that + // wrote it rather than the neighbor that handed it over. + Log.i(TAG, "Relayed message from $originator via $relayPath: '$trimmed'") + broadcastMessageReceived( + originator, trimmed, snr, freq, carriersOf(relayPath, endIsOriginator = true), originator + ) + broadcastQueueTx("$relayPath ACK", null, priority = 2) + } + + /** + * The stations that carried a message, in the order it travelled, which + * is what a thread shows. A return path runs the other way and ends at + * the station we were talking to, so it is reversed and, when that far + * end wrote the message rather than carrying it, trimmed. + */ + private fun carriersOf(relayPath: String, endIsOriginator: Boolean): String? { + val hops = RelayPath.parse(relayPath) + val carriers = if (endIsOriginator) hops.dropLast(1) else hops + return RelayPath.format(carriers.reversed()) + } + + /** Remove the "*DE* CALL" trail each hop appends, leaving the original text. */ + private fun stripRelayAttribution(payload: String): String { + return relayPathRegex.replace(payload, "").trim() + } + + /** + * An answer to a mailbox question we asked over this path. Keyed on the + * originator, because the station handing it to us is only the near hop. + */ + private fun handleRelayedMailboxReply( + originator: String, + relayPath: String, + payload: String, + snr: Int, + freq: Float + ): Boolean { + if (!expectingMailFrom(originator)) return false + + Regex("^YES\\s+MSG\\s+ID\\s+(\\d+)", RegexOption.IGNORE_CASE).find(payload)?.let { + val id = it.groupValues[1].toLong() + if (!mailboxReplyAllowed(originator, "FETCH $id")) return true + pendingMailRetrievals[originator] = System.currentTimeMillis() + Log.i(TAG, "Fetching mailbox message $id from $originator via $relayPath") + broadcastQueueTx("$relayPath QUERY MSG $id", null, priority = 1) + return true + } + + if (payload.equals("NO", ignoreCase = true)) { + Log.i(TAG, "No mail waiting at $originator (via $relayPath)") + broadcastMailboxEmpty(originator) + return true + } + + if (payload.startsWith("MSG ", ignoreCase = true)) { + val body = payload.substring(4).trim() + val delivered = parseDeliveredMail(originator, body) ?: return false + Log.i( + TAG, + "Mailbox mail collected via relay: originator=${delivered.originator} " + + "path=$relayPath next=${delivered.nextId}" + ) + // The far end here is the mailbox that held the message, so it + // carried it and stays in the list. The author is separate. + broadcastMessageReceived( + delivered.originator, delivered.text, snr, freq, + carriersOf(relayPath, endIsOriginator = false), delivered.originator + ) + delivered.nextId?.let { next -> + if (mailboxReplyAllowed(originator, "FETCH $next")) { + pendingMailRetrievals[originator] = System.currentTimeMillis() + broadcastQueueTx("$relayPath QUERY MSG $next", null, priority = 1) + } + } + return true + } + + return false + } + + /** A mailbox question asked of us over a path. Answers ride back down it. */ + private fun handleRelayedMailboxQuery( + callsign: String, + originator: String, + relayPath: String, + payload: String, + snr: Int, + freq: Float + ): Boolean { + if (isSelfCallsign(callsign, originator)) return false + + if (payload.equals("QUERY MSGS", ignoreCase = true) || + payload.equals("QUERY MSGS?", ignoreCase = true) + ) { + serveQueryMsgs(callsign, originator, groupAddressed = false, replyPath = relayPath) + return true + } + + Regex("^QUERY\\s+MSG\\s+(\\d+)$", RegexOption.IGNORE_CASE).matchEntire(payload)?.let { + serveQueryMsg(callsign, originator, it.groupValues[1].toLong(), replyPath = relayPath) + return true + } + + if (payload.startsWith("MSG TO:", ignoreCase = true)) { + if (!isMailboxEnabled()) { + Log.i(TAG, "Relayed mailbox deposit from=$originator ignored: mailbox disabled") + return true + } + completeMailboxDeposit( + from = originator, + payload = payload.substring("MSG TO:".length).trim(), + snr = snr, + freq = freq, + originatorOverride = originator, + replyPath = relayPath + ) + return true + } + + return false } private fun buildRelayForwardPayload(message: String): String? { @@ -3572,25 +4404,30 @@ class JS8EngineService : Service() { val payload = text.trim() if (payload.isEmpty()) return false - prepareEngineForTransmit(activeEngine) - val ok = activeEngine.transmitMessage( - text = payload, - myCall = callsign, - myGrid = grid, - selectedCall = "", - submode = submode, - audioFrequencyHz = currentTxOffsetHz.toDouble(), - txDelaySec = 0.0, - forceIdentify = callsign.isNotBlank(), - forceData = false - ) + scheduleAtNextTxBoundary(submode, mainHandler) { + if (engine !== activeEngine || activeEngine.isTransmitting()) { + return@scheduleAtNextTxBoundary + } + prepareEngineForTransmit(activeEngine) + val ok = activeEngine.transmitMessage( + text = payload, + myCall = callsign, + myGrid = grid, + selectedCall = "", + submode = submode, + audioFrequencyHz = currentTxOffsetHz.toDouble(), + txDelaySec = TX_BOUNDARY_DELAY_S, + forceIdentify = callsign.isNotBlank(), + forceData = false + ) - if (ok) { - updateLastTxMessage(payload, "", submode, currentTxOffsetHz.toDouble()) - broadcastTxState(TX_STATE_QUEUED) - startTxMonitor() + if (ok) { + updateLastTxMessage(payload, "", submode, currentTxOffsetHz.toDouble()) + broadcastTxState(TX_STATE_QUEUED) + startTxMonitor() + } } - return ok + return true } private fun sendAutoReply( @@ -3613,28 +4450,33 @@ class JS8EngineService : Service() { val directedCall = directed?.trim().orEmpty().uppercase() if (requireDirected && directedCall.isBlank()) return - prepareEngineForTransmit(activeEngine) - val ok = activeEngine.transmitMessage( - text = payloadText, - myCall = callsign, - myGrid = grid, - selectedCall = directedCall, - submode = submode, - audioFrequencyHz = currentTxOffsetHz.toDouble(), - txDelaySec = 0.0, - forceIdentify = callsign.isNotBlank(), - forceData = forceData - ) + scheduleAtNextTxBoundary(submode, mainHandler) { + if (engine !== activeEngine || activeEngine.isTransmitting()) { + return@scheduleAtNextTxBoundary + } + prepareEngineForTransmit(activeEngine) + val ok = activeEngine.transmitMessage( + text = payloadText, + myCall = callsign, + myGrid = grid, + selectedCall = directedCall, + submode = submode, + audioFrequencyHz = currentTxOffsetHz.toDouble(), + txDelaySec = TX_BOUNDARY_DELAY_S, + forceIdentify = callsign.isNotBlank(), + forceData = forceData + ) - if (ok) { - Log.i(TAG, "Autoreply queued: to=$directedCall text='$payloadText'") - updateLastTxMessage(payloadText, directedCall, submode, currentTxOffsetHz.toDouble()) - broadcastTxState(TX_STATE_QUEUED) - startTxMonitor() - } else { - Log.e(TAG, "Autoreply rejected") - broadcastError("Failed to start transmit") - broadcastTxState(TX_STATE_FAILED) + if (ok) { + Log.i(TAG, "Autoreply queued: to=$directedCall text='$payloadText'") + updateLastTxMessage(payloadText, directedCall, submode, currentTxOffsetHz.toDouble()) + broadcastTxState(TX_STATE_QUEUED) + startTxMonitor() + } else { + Log.e(TAG, "Autoreply rejected") + broadcastError("Failed to start transmit") + broadcastTxState(TX_STATE_FAILED) + } } } @@ -3647,6 +4489,47 @@ class JS8EngineService : Service() { } } + /** + * Mine one decoded frame for who-hears-whom evidence and store it. Runs + * on every decode, addressed to us or not: overheard heartbeat ACKs and + * SNR reports between other stations are what the network map is made of. + */ + private fun recordLinkEvidence(text: String, snr: Int) { + val callsign = getConfiguredCallsign() ?: return + storeLinkObservations(LinkEvidence.fromDecode(callsign, text, snr)) + } + + /** + * Mine a reassembled relay payload's *DE* trail: each hop demonstrably + * received a checksummed transfer from the station before it. + */ + private fun recordRelayLinkEvidence(transmitter: String, payload: String) { + storeLinkObservations(LinkEvidence.fromRelayChain(transmitter, payload)) + } + + private fun storeLinkObservations(observations: List) { + if (observations.isEmpty()) return + val now = System.currentTimeMillis() + val dial = currentDialHz.takeIf { it > 0 } + val rows = observations.map { + LinkObservationEntity( + reporter = it.reporter, + heard = it.heard, + snr = it.snr, + source = it.source.name, + dialFreqHz = dial, + observedAt = now + ) + } + serviceScope.launch { linkRepository.record(rows) } + } + + private fun pruneLinkObservations() { + val prefs = PreferenceManager.getDefaultSharedPreferences(this) + val days = prefs.getString(PREF_LINK_RETENTION_DAYS, "30")?.toLongOrNull() ?: 30L + serviceScope.launch { linkRepository.pruneOlderThan(days * 24 * 60 * 60 * 1000L) } + } + private fun getRecentHeardCallsigns(exclude: Set, limit: Int): List { val now = System.currentTimeMillis() synchronized(heardLock) { @@ -3810,15 +4693,24 @@ class JS8EngineService : Service() { private const val TAG = "JS8EngineService" private const val PREF_AUTOREPLY_ENABLED = "autoreply_enabled" private const val PREF_RELAY_ENABLED = "relay_enabled" + private const val PREF_MAILBOX_ENABLED = "mailbox_enabled" + // No UI yet: a diagnostic gate for rejecting deposits whose checksum + // fails, in case our reassembly disagrees with the desktop's spacing. + private const val PREF_MAILBOX_STRICT_CHECKSUM = "mailbox_strict_checksum" private const val PREF_TX_SUBMODE = "tx_submode" private const val PREF_MY_INFO = "my_info" private const val PREF_MY_STATUS = "my_status" private const val PREF_PSK_REPORTER = "psk_reporter" private const val PREF_TRUSDX_DIAGNOSTICS_ENABLED = "trusdx_diagnostics_enabled" + private const val PREF_LINK_RETENTION_DAYS = "link_retention_days" private const val HEARD_LIMIT = 4 private const val HEARD_WINDOW_MS = 15 * 60 * 1000L private val HEARD_EXCLUDE_TOKENS = setOf("CQ", "HB", "HEARTBEAT", "ALLCALL", "@ALLCALL") private const val RELAY_BUFFER_TIMEOUT_MS = 90_000L + // Fire this far before the boundary; the delay must exceed the lead + // or the modulator joins the frame already in progress. + private const val TX_BOUNDARY_LEAD_MS = 1500L + private const val TX_BOUNDARY_DELAY_S = 2.0 private const val RELAY_FREQUENCY_TOLERANCE_HZ = 10.0f private const val RELAY_EOM_MARKER = "\u2662" private const val SUBMODE_NORMAL = 0 @@ -3852,12 +4744,29 @@ class JS8EngineService : Service() { const val ACTION_ERROR = "com.js8call.example.ACTION_ERROR" const val ACTION_TRANSMIT_MESSAGE = "com.js8call.example.ACTION_TRANSMIT_MESSAGE" const val ACTION_TX_STATE = "com.js8call.example.ACTION_TX_STATE" + const val ACTION_TX_SENT = "com.js8call.example.ACTION_TX_SENT" + const val ACTION_TX_PROGRESS = "com.js8call.example.ACTION_TX_PROGRESS" const val ACTION_RADIO_FREQUENCY = "com.js8call.example.ACTION_RADIO_FREQUENCY" const val ACTION_MESSAGE_RECEIVED = "com.js8call.example.ACTION_MESSAGE_RECEIVED" + const val ACTION_MESSAGE_ACKED = "com.js8call.example.ACTION_MESSAGE_ACKED" + const val EXTRA_MESSAGE_SILENT = "message_silent" + const val ACTION_MAILBOX_EMPTY = "com.js8call.example.ACTION_MAILBOX_EMPTY" const val ACTION_QUEUE_TX = "com.js8call.example.ACTION_QUEUE_TX" const val ACTION_TIME_SYNC_ONCE = "com.js8call.example.ACTION_TIME_SYNC_ONCE" const val ACTION_SET_TIME_DRIFT = "com.js8call.example.ACTION_SET_TIME_DRIFT" const val ACTION_TIME_DRIFT = "com.js8call.example.ACTION_TIME_DRIFT" + const val ACTION_TIMING_SUGGESTION = "com.js8call.example.ACTION_TIMING_SUGGESTION" + const val ACTION_DEBUG_INJECT_TIMING = "com.js8call.example.ACTION_DEBUG_INJECT_TIMING" + const val EXTRA_TIMING_KIND = "timing_kind" + const val EXTRA_TIMING_STEP = "timing_step" + const val EXTRA_TIMING_STEPS = "timing_steps" + const val EXTRA_TIMING_PERIOD_MS = "timing_period_ms" + const val TIMING_SEARCHING = 0 + const val TIMING_FOUND = 1 + const val TIMING_GAVE_UP = 2 + const val ACTION_RIG_STATUS = "com.js8call.example.ACTION_RIG_STATUS" + // Debug builds only; ignored in release. See onStartCommand. + const val ACTION_DEBUG_INJECT_DECODE = "com.js8call.example.ACTION_DEBUG_INJECT_DECODE" // Engine states const val STATE_STOPPED = "stopped" @@ -3877,6 +4786,7 @@ class JS8EngineService : Service() { const val EXTRA_MODE = "mode" const val EXTRA_DRIFT_MS = "drift_ms" const val EXTRA_TIME_DRIFT_MS = "time_drift_ms" + const val EXTRA_RIG_CONNECTED = "rig_connected" const val EXTRA_BINS = "bins" const val EXTRA_BIN_HZ = "bin_hz" const val EXTRA_POWER_DB = "power_db" @@ -3896,6 +4806,10 @@ class JS8EngineService : Service() { const val EXTRA_TX_FORCE_IDENTIFY = "tx_force_identify" const val EXTRA_TX_FORCE_DATA = "tx_force_data" const val EXTRA_TX_STATE = "tx_state" + const val EXTRA_TX_FRAME_INDEX = "tx_frame_index" + const val EXTRA_TX_FRAME_COUNT = "tx_frame_count" + const val EXTRA_TX_SENT_TEXT = "tx_sent_text" + const val EXTRA_TX_SENT_FREQ = "tx_sent_freq" const val EXTRA_RADIO_FREQUENCY_HZ = "radio_frequency_hz" const val EXTRA_MESSAGE_FROM = "message_from" const val EXTRA_MESSAGE_TEXT = "message_text" @@ -3906,6 +4820,10 @@ class JS8EngineService : Service() { const val EXTRA_QUEUE_TX_TEXT = "queue_tx_text" const val EXTRA_QUEUE_TX_DIRECTED = "queue_tx_directed" const val EXTRA_QUEUE_TX_PRIORITY = "queue_tx_priority" + // A mailbox delivery in flight: the row to mark once the send finishes. + // A recipient callsign means a group message, recorded per callsign. + const val EXTRA_QUEUE_TX_MAILBOX_ID = "queue_tx_mailbox_id" + const val EXTRA_QUEUE_TX_MAILBOX_RECIPIENT = "queue_tx_mailbox_recipient" const val PREF_TRANSMIT_MODE = "transmit_mode" const val PREF_HEARTBEAT_INTERVAL = "heartbeat_interval" const val PREF_TIME_SYNC_AUTO = "time_sync_auto" @@ -3933,6 +4851,7 @@ class JS8EngineService : Service() { private const val PTT_COMMAND_RETRIES = 1 private const val TX_PREKEY_MONITOR_INTERVAL_MS = 25L private const val TX_MONITOR_INTERVAL_MS = 250L + private const val RIG_STATUS_POLL_INTERVAL_MS = 2000L private const val SCO_START_WAIT_INTERVAL_MS = 200L private const val SCO_START_MAX_ATTEMPTS = 10 private const val SCO_SILENCE_CHECK_DELAY_MS = 2000L diff --git a/android/app/src/main/java/com/js8call/example/ui/AudioDevices.kt b/android/app/src/main/java/com/js8call/example/ui/AudioDevices.kt new file mode 100644 index 000000000..8d1f02e70 --- /dev/null +++ b/android/app/src/main/java/com/js8call/example/ui/AudioDevices.kt @@ -0,0 +1,130 @@ +package com.js8call.example.ui + +import android.content.Context +import android.content.Intent +import android.media.AudioDeviceInfo +import android.media.AudioManager +import android.os.Build +import androidx.preference.PreferenceManager +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.js8call.example.R +import com.js8call.example.service.JS8EngineService + +/** + * The capture inputs the engine can listen on, and the picker both screens show. + * + * The Monitor strip and Settings offer the same choice, so the list, the stored + * selection and the dialog live here rather than in either fragment. + */ +object AudioDevices { + + const val PREF_SELECTED_ID = "last_audio_device_id" + + data class Device(val id: Int, val name: String) { + override fun toString(): String = name + } + + /** + * Inputs available right now, in the order they are offered. + * + * A TruSDX rig replaces the list: its audio arrives over the serial link, + * so the phone's own inputs cannot carry it. + */ + fun list(context: Context): List { + val prefs = PreferenceManager.getDefaultSharedPreferences(context) + if (prefs.getString("rig_type", "none") == "trusdx_serial") { + return listOf( + Device(JS8EngineService.TRUSDX_AUDIO_SERIAL_ID, "TruSDX Serial"), + Device(JS8EngineService.TRUSDX_AUDIO_SPEAKER_ID, "TruSDX Speaker") + ) + } + + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) { + return listOf(Device(DEFAULT_DEVICE_ID, "Default Microphone")) + } + + val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager + val devices = mutableListOf() + for (device in audioManager.getDevices(AudioManager.GET_DEVICES_INPUTS)) { + if (!device.isSource) continue + val name = when (device.type) { + AudioDeviceInfo.TYPE_BUILTIN_MIC -> "Internal Microphone" + AudioDeviceInfo.TYPE_WIRED_HEADSET -> "Wired Headset" + AudioDeviceInfo.TYPE_USB_DEVICE -> device.productName?.toString() ?: "USB Audio Device" + AudioDeviceInfo.TYPE_USB_ACCESSORY -> "USB Audio Accessory" + AudioDeviceInfo.TYPE_USB_HEADSET -> "USB Headset" + AudioDeviceInfo.TYPE_BLUETOOTH_SCO -> "Bluetooth Headset" + AudioDeviceInfo.TYPE_BLUETOOTH_A2DP -> "Bluetooth Audio" + AudioDeviceInfo.TYPE_LINE_ANALOG -> "Line Input" + AudioDeviceInfo.TYPE_LINE_DIGITAL -> "Digital Line Input" + else -> continue // Skip unknown types + } + devices.add(Device(device.id, name)) + } + + if (devices.isEmpty()) { + devices.add(Device(DEFAULT_DEVICE_ID, "Default Microphone")) + } + return devices + } + + /** + * The device the engine will capture from, resolved against what is + * plugged in now. A saved device that has since been unplugged falls back + * to the first one available. + */ + fun selected(context: Context): Device? { + val devices = list(context) + if (devices.isEmpty()) return null + val savedId = PreferenceManager.getDefaultSharedPreferences(context) + .getInt(PREF_SELECTED_ID, DEFAULT_DEVICE_ID) + return devices.firstOrNull { it.id == savedId } ?: devices.first() + } + + fun selectedName(context: Context): String? = selected(context)?.name + + /** + * Remember the choice, and move a live capture onto it. + * A stopped engine reads the saved choice when it next starts. + */ + fun select(context: Context, device: Device, engineRunning: Boolean) { + PreferenceManager.getDefaultSharedPreferences(context) + .edit() + .putInt(PREF_SELECTED_ID, device.id) + .apply() + + if (!engineRunning) return + val intent = Intent(context, JS8EngineService::class.java).apply { + action = JS8EngineService.ACTION_SWITCH_AUDIO_DEVICE + putExtra(JS8EngineService.EXTRA_AUDIO_DEVICE_ID, device.id) + } + context.startService(intent) + } + + /** + * Show the picker. [onSelected] runs after the choice is stored, so the + * caller only has to refresh whatever it shows the device on. + */ + fun showPicker( + context: Context, + engineRunning: Boolean, + onSelected: (Device) -> Unit + ) { + val devices = list(context) + if (devices.isEmpty()) return + val current = selected(context) + val checked = devices.indexOfFirst { it.id == current?.id } + + MaterialAlertDialogBuilder(context) + .setTitle(R.string.monitor_menu_audio_device) + .setSingleChoiceItems(devices.map { it.name }.toTypedArray(), checked) { dialog, which -> + dialog.dismiss() + val device = devices[which] + select(context, device, engineRunning) + onSelected(device) + } + .show() + } + + private const val DEFAULT_DEVICE_ID = -1 +} diff --git a/android/app/src/main/java/com/js8call/example/ui/BareDecodeFragment.kt b/android/app/src/main/java/com/js8call/example/ui/BareDecodeFragment.kt new file mode 100644 index 000000000..bb427e141 --- /dev/null +++ b/android/app/src/main/java/com/js8call/example/ui/BareDecodeFragment.kt @@ -0,0 +1,10 @@ +package com.js8call.example.ui + +import com.js8call.example.R + +/** + * Decode list without the card and header, for the Everything thread. + */ +class BareDecodeFragment : DecodeFragment() { + override val layoutRes: Int = R.layout.fragment_decodes_bare +} diff --git a/android/app/src/main/java/com/js8call/example/ui/ComposeBarController.kt b/android/app/src/main/java/com/js8call/example/ui/ComposeBarController.kt new file mode 100644 index 000000000..e9086fe92 --- /dev/null +++ b/android/app/src/main/java/com/js8call/example/ui/ComposeBarController.kt @@ -0,0 +1,69 @@ +package com.js8call.example.ui + +import android.view.View +import android.view.inputmethod.EditorInfo +import android.widget.PopupMenu +import androidx.annotation.MenuRes +import android.widget.EditText +import com.google.android.material.button.MaterialButton +import com.js8call.example.R + +/** + * Wires up the shared compose bar: command menu, text field, and send arrow. + * + * The command menu holds one-shot sends: CQ/Heartbeat in the Everything + * thread, the directed queries (SNR?, GRID?, ...) in a DM thread. + */ +class ComposeBarController( + root: View, + @MenuRes private val commandMenuRes: Int, + private val onSend: (String) -> Unit, + private val onCommand: (String) -> Unit +) { + + private val commandButton: MaterialButton = root.findViewById(R.id.command_button) + private val input: EditText = root.findViewById(R.id.compose_input) + private val sendButton: View = root.findViewById(R.id.send_button) + + init { + commandButton.setOnClickListener { showCommandMenu() } + sendButton.setOnClickListener { send() } + input.setOnEditorActionListener { _, actionId, _ -> + if (actionId == EditorInfo.IME_ACTION_SEND) { + send() + true + } else { + false + } + } + } + + private fun send() { + val text = input.text?.toString()?.trim().orEmpty() + if (text.isEmpty()) return + input.text?.clear() + onSend(text) + } + + private fun showCommandMenu() { + val popup = PopupMenu(commandButton.context, commandButton) + popup.menuInflater.inflate(commandMenuRes, popup.menu) + popup.setOnMenuItemClickListener { item -> + val command = when (item.itemId) { + R.id.cmd_cq -> "CQ CQ CQ" + R.id.cmd_hb -> "HB" + R.id.cmd_snr -> "SNR?" + R.id.cmd_grid -> "GRID?" + R.id.cmd_info -> "INFO?" + R.id.cmd_status -> "STATUS?" + R.id.cmd_hearing -> "HEARING?" + R.id.cmd_agn -> "AGN?" + R.id.cmd_query_msgs -> "QUERY MSGS?" + else -> return@setOnMenuItemClickListener false + } + onCommand(command) + true + } + popup.show() + } +} diff --git a/android/app/src/main/java/com/js8call/example/ui/ContactDetailFragment.kt b/android/app/src/main/java/com/js8call/example/ui/ContactDetailFragment.kt new file mode 100644 index 000000000..b5c3096ae --- /dev/null +++ b/android/app/src/main/java/com/js8call/example/ui/ContactDetailFragment.kt @@ -0,0 +1,289 @@ +package com.js8call.example.ui + +import android.content.res.ColorStateList +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.LinearLayout +import android.widget.TextView +import androidx.core.content.ContextCompat +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModelProvider +import androidx.navigation.fragment.findNavController +import com.google.android.material.appbar.MaterialToolbar +import com.google.android.material.button.MaterialButton +import com.google.android.material.chip.Chip +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.google.android.material.snackbar.Snackbar +import com.google.android.material.textfield.TextInputEditText +import com.js8call.example.R +import com.js8call.example.data.ContactEntity +import com.js8call.example.util.AvatarColor +import com.js8call.example.util.DisplayName +import com.js8call.example.util.Maidenhead +import com.js8call.example.util.RelayPath +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +/** + * Everything the app knows about one station, and everywhere the operator + * can change it. Reached from the contact list, from a thread's toolbar, + * and from a sender's name on a group message. + * + * The name and notes save when a field loses focus and again when the + * screen stops, so there is no Save button to forget. + */ +class ContactDetailFragment : Fragment() { + + private lateinit var viewModel: ContactsViewModel + private lateinit var messagesViewModel: MessagesViewModel + + private lateinit var toolbar: MaterialToolbar + private lateinit var avatarFrame: View + private lateinit var avatarText: TextView + private lateinit var nameText: TextView + private lateinit var callsignText: TextView + private lateinit var hearsUsChip: Chip + private lateinit var messageButton: MaterialButton + private lateinit var mailText: TextView + private lateinit var relayPathValue: TextView + private lateinit var nameInput: TextInputEditText + private lateinit var notesInput: TextInputEditText + private lateinit var heardContainer: LinearLayout + private lateinit var neverHeardText: TextView + + private var callsign: String = "" + private var contact: ContactEntity? = null + + /** Seed the editable fields once, so live updates cannot fight the typist. */ + private var fieldsSeeded = false + + private val timeFormat = SimpleDateFormat("MMM d, h:mm a", Locale.getDefault()) + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + callsign = arguments?.getString("callsign")?.trim()?.uppercase() ?: "" + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View? { + return inflater.inflate(R.layout.fragment_contact_detail, container, false) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + viewModel = ViewModelProvider(requireActivity())[ContactsViewModel::class.java] + messagesViewModel = ViewModelProvider(requireActivity())[MessagesViewModel::class.java] + + bindViews(view) + + toolbar.setNavigationOnClickListener { findNavController().navigateUp() } + toolbar.inflateMenu(R.menu.contact_detail_menu) + toolbar.setOnMenuItemClickListener { item -> + when (item.itemId) { + R.id.action_favorite -> { + val starred = contact?.starred ?: false + viewModel.setStarred(callsign, !starred) + true + } + R.id.action_delete_contact -> { + confirmDelete() + true + } + else -> false + } + } + + avatarText.text = DisplayName.initial(callsign, null) + avatarFrame.backgroundTintList = ColorStateList.valueOf( + ContextCompat.getColor(requireContext(), AvatarColor.forCallsign(callsign)) + ) + nameText.text = callsign + + messageButton.setOnClickListener { openConversation() } + view.findViewById(R.id.relay_path_row).setOnClickListener { openRelayPath() } + + // Leaving a field commits it, which is what makes the Save button + // unnecessary. onStop covers backing out without moving focus first. + nameInput.setOnFocusChangeListener { _, hasFocus -> if (!hasFocus) saveName() } + notesInput.setOnFocusChangeListener { _, hasFocus -> if (!hasFocus) saveNotes() } + + viewModel.getContact(callsign).observe(viewLifecycleOwner) { entity -> + contact = entity + render(entity) + } + + messagesViewModel.getRelayPath(callsign).observe(viewLifecycleOwner) { stored -> + relayPathValue.text = describePath(RelayPath.parse(stored)) + } + + viewModel.getHeldMailCount(callsign).observe(viewLifecycleOwner) { count -> + if (count > 0) { + mailText.visibility = View.VISIBLE + mailText.text = resources.getQuantityString( + R.plurals.contact_detail_mail, count, count + ) + } else { + mailText.visibility = View.GONE + } + } + } + + override fun onStop() { + saveName() + saveNotes() + super.onStop() + } + + private fun bindViews(view: View) { + toolbar = view.findViewById(R.id.contact_toolbar) + avatarFrame = view.findViewById(R.id.avatar_frame) + avatarText = view.findViewById(R.id.avatar_text) + nameText = view.findViewById(R.id.name_text) + callsignText = view.findViewById(R.id.callsign_text) + hearsUsChip = view.findViewById(R.id.hears_us_chip) + messageButton = view.findViewById(R.id.message_button) + mailText = view.findViewById(R.id.mail_text) + relayPathValue = view.findViewById(R.id.relay_path_value) + nameInput = view.findViewById(R.id.name_input) + notesInput = view.findViewById(R.id.notes_input) + heardContainer = view.findViewById(R.id.heard_container) + neverHeardText = view.findViewById(R.id.never_heard_text) + } + + /** A station with no row yet still gets a card, just an empty one. */ + private fun render(entity: ContactEntity?) { + val name = entity?.name + + nameText.text = DisplayName.of(callsign, name) + avatarText.text = DisplayName.initial(callsign, name) + val secondary = DisplayName.secondary(callsign, name) + callsignText.text = secondary.orEmpty() + callsignText.visibility = if (secondary == null) View.GONE else View.VISIBLE + + hearsUsChip.visibility = if (entity?.heardUs == true) View.VISIBLE else View.GONE + + val starred = entity?.starred == true + toolbar.menu.findItem(R.id.action_favorite)?.apply { + setIcon(if (starred) R.drawable.ic_star else R.drawable.ic_star_outline) + setTitle(if (starred) R.string.contact_detail_unfavorite else R.string.contact_star) + iconTintList = ColorStateList.valueOf( + ContextCompat.getColor( + requireContext(), + if (starred) R.color.star_active else R.color.star_inactive + ) + ) + } + + if (!fieldsSeeded) { + fieldsSeeded = true + nameInput.setText(name.orEmpty()) + notesInput.setText(entity?.comment.orEmpty()) + } + + renderHeard(entity) + } + + private fun renderHeard(entity: ContactEntity?) { + heardContainer.removeAllViews() + + // lastHeard of 0 marks a row created by naming a station rather than + // by hearing one, so it has nothing to report. + val heard = entity != null && entity.lastHeard > 0L + neverHeardText.visibility = if (heard) View.GONE else View.VISIBLE + if (!heard || entity == null) return + + addRow(R.string.contact_detail_last_heard, timeFormat.format(Date(entity.lastHeard))) + entity.snr?.let { addRow(R.string.contact_detail_snr_label, getString(R.string.contact_snr, it)) } + entity.offset?.let { + addRow(R.string.contact_detail_offset_label, getString(R.string.contact_offset, it.toInt())) + } + entity.grid?.takeIf { it.isNotBlank() }?.let { grid -> + addRow(R.string.contact_detail_grid_label, grid) + // From the operator's own grid to theirs, when both are known. + // Grid centers, so this is an estimate by nature. + Maidenhead.describePath(myGrid(), grid, miles = useMiles())?.let { + addRow(R.string.contact_detail_distance_label, it) + } + } + entity.info?.takeIf { it.isNotBlank() }?.let { addRow(R.string.contact_detail_info_label, it) } + } + + private fun addRow(labelRes: Int, value: String) { + val row = layoutInflater.inflate(R.layout.item_contact_detail_row, heardContainer, false) + row.findViewById(R.id.row_label).setText(labelRes) + row.findViewById(R.id.row_value).text = value + heardContainer.addView(row) + } + + private fun myGrid(): String? = + androidx.preference.PreferenceManager.getDefaultSharedPreferences(requireContext()) + .getString("grid", null)?.trim()?.takeIf { it.isNotEmpty() } + + private fun useMiles(): Boolean = + androidx.preference.PreferenceManager.getDefaultSharedPreferences(requireContext()) + .getString("distance_units", "mi") != "km" + + private fun describePath(hops: List): String { + if (hops.isEmpty()) return getString(R.string.relay_direct) + val count = resources.getQuantityString(R.plurals.relay_hop_count, hops.size, hops.size) + return "$count · " + hops.joinToString(" › ") + } + + private fun saveName() { + if (!fieldsSeeded) return + val typed = nameInput.text?.toString()?.trim().orEmpty() + val current = contact?.name?.trim().orEmpty() + if (typed == current) return + viewModel.setName(callsign, typed.takeIf { it.isNotEmpty() }) + } + + private fun saveNotes() { + if (!fieldsSeeded) return + val typed = notesInput.text?.toString()?.trim().orEmpty() + val current = contact?.comment?.trim().orEmpty() + if (typed == current) return + viewModel.setComment(callsign, typed.takeIf { it.isNotEmpty() }) + } + + private fun openConversation() { + findNavController().navigate( + R.id.navigation_conversation, + Bundle().apply { putString("callsign", callsign) } + ) + } + + private fun openRelayPath() { + findNavController().navigate( + R.id.navigation_relay_path, + Bundle().apply { putString("callsign", callsign) } + ) + } + + private fun confirmDelete() { + MaterialAlertDialogBuilder(requireContext()) + .setTitle(getString(R.string.contact_delete_title, callsign)) + .setMessage(R.string.contact_delete_message) + .setPositiveButton(R.string.contact_action_delete) { _, _ -> + // Do not let onStop write the fields back into a row we + // just removed. + fieldsSeeded = false + contact = null + viewModel.deleteContact(callsign) + findNavController().navigateUp() + requireActivity().findViewById(android.R.id.content)?.let { + Snackbar.make( + it, getString(R.string.contact_deleted, callsign), Snackbar.LENGTH_SHORT + ).show() + } + } + .setNegativeButton(android.R.string.cancel, null) + .show() + } +} diff --git a/android/app/src/main/java/com/js8call/example/ui/ContactListAdapter.kt b/android/app/src/main/java/com/js8call/example/ui/ContactListAdapter.kt new file mode 100644 index 000000000..12b1dbf7b --- /dev/null +++ b/android/app/src/main/java/com/js8call/example/ui/ContactListAdapter.kt @@ -0,0 +1,114 @@ +package com.js8call.example.ui + +import android.content.res.ColorStateList +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.ImageView +import android.widget.TextView +import androidx.core.content.ContextCompat +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListAdapter +import androidx.recyclerview.widget.RecyclerView +import com.google.android.material.button.MaterialButton +import com.js8call.example.R +import com.js8call.example.data.ContactEntity +import com.js8call.example.util.AvatarColor +import com.js8call.example.util.DisplayName + +/** + * Adapter for the heard-station contact list. + */ +class ContactListAdapter : ListAdapter(DIFF) { + + var onItemClick: ((ContactEntity) -> Unit)? = null + var onStarClick: ((ContactEntity) -> Unit)? = null + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ContactViewHolder { + val view = LayoutInflater.from(parent.context) + .inflate(R.layout.item_contact, parent, false) + return ContactViewHolder(view) + } + + override fun onBindViewHolder(holder: ContactViewHolder, position: Int) { + holder.bind(getItem(position)) + } + + inner class ContactViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { + private val avatarFrame: View = itemView.findViewById(R.id.avatar_frame) + private val avatarText: TextView = itemView.findViewById(R.id.avatar_text) + private val callsignText: TextView = itemView.findViewById(R.id.callsign_text) + private val hearsUsIcon: ImageView = itemView.findViewById(R.id.hears_us_icon) + private val detailText: TextView = itemView.findViewById(R.id.detail_text) + private val infoText: TextView = itemView.findViewById(R.id.info_text) + private val commentText: TextView = itemView.findViewById(R.id.comment_text) + private val ageText: TextView = itemView.findViewById(R.id.age_text) + private val starButton: MaterialButton = itemView.findViewById(R.id.star_button) + + fun bind(contact: ContactEntity) { + avatarText.text = DisplayName.initial(contact.callsign, contact.name) + avatarFrame.backgroundTintList = ColorStateList.valueOf( + ContextCompat.getColor(itemView.context, AvatarColor.forCallsign(contact.callsign)) + ) + callsignText.text = DisplayName.of(contact.callsign, contact.name) + hearsUsIcon.visibility = if (contact.heardUs) View.VISIBLE else View.GONE + + val res = itemView.resources + val parts = mutableListOf() + // A named station shows its callsign here, since the headline + // gave the row to the name. + DisplayName.secondary(contact.callsign, contact.name)?.let { parts.add(it) } + contact.snr?.let { parts.add(res.getString(R.string.contact_snr, it)) } + contact.offset?.let { parts.add(res.getString(R.string.contact_offset, it.toInt())) } + contact.grid?.let { parts.add(it) } + detailText.text = parts.joinToString(" · ") + detailText.visibility = if (parts.isEmpty()) View.GONE else View.VISIBLE + + if (contact.info.isNullOrBlank()) { + infoText.visibility = View.GONE + } else { + infoText.visibility = View.VISIBLE + infoText.text = contact.info + } + + if (contact.comment.isNullOrBlank()) { + commentText.visibility = View.GONE + } else { + commentText.visibility = View.VISIBLE + commentText.text = contact.comment + } + + ageText.text = formatAge(contact.lastHeard) + + starButton.setIconResource( + if (contact.starred) R.drawable.ic_star else R.drawable.ic_star_outline + ) + starButton.setIconTintResource( + if (contact.starred) R.color.star_active else R.color.star_inactive + ) + + itemView.setOnClickListener { onItemClick?.invoke(contact) } + starButton.setOnClickListener { onStarClick?.invoke(contact) } + } + + private fun formatAge(timestamp: Long): String { + val seconds = (System.currentTimeMillis() - timestamp) / 1000 + return when { + seconds < 60 -> itemView.resources.getString(R.string.contact_age_now) + seconds < 3600 -> "${seconds / 60}m" + seconds < 86400 -> "${seconds / 3600}h" + else -> "${seconds / 86400}d" + } + } + } + + companion object { + private val DIFF = object : DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: ContactEntity, newItem: ContactEntity) = + oldItem.callsign == newItem.callsign + + override fun areContentsTheSame(oldItem: ContactEntity, newItem: ContactEntity) = + oldItem == newItem + } + } +} diff --git a/android/app/src/main/java/com/js8call/example/ui/ContactsFragment.kt b/android/app/src/main/java/com/js8call/example/ui/ContactsFragment.kt new file mode 100644 index 000000000..4c0cbcb6b --- /dev/null +++ b/android/app/src/main/java/com/js8call/example/ui/ContactsFragment.kt @@ -0,0 +1,121 @@ +package com.js8call.example.ui + +import android.os.Bundle +import android.os.Handler +import android.os.Looper +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.TextView +import androidx.core.widget.addTextChangedListener +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModelProvider +import androidx.navigation.fragment.findNavController +import androidx.recyclerview.widget.RecyclerView +import com.google.android.material.textfield.TextInputEditText +import com.js8call.example.R + +/** + * Contacts tab: every station heard on the air, starred first, + * then most recently heard. + */ +class ContactsFragment : Fragment() { + + private lateinit var viewModel: ContactsViewModel + private lateinit var adapter: ContactListAdapter + private lateinit var recyclerView: RecyclerView + private lateinit var emptyState: View + private lateinit var emptyTitle: TextView + private lateinit var emptyHint: TextView + private lateinit var searchInput: TextInputEditText + + // Refresh the age column while the list is on screen + private val ageHandler = Handler(Looper.getMainLooper()) + private val ageTick = object : Runnable { + override fun run() { + adapter.notifyItemRangeChanged(0, adapter.itemCount) + ageHandler.postDelayed(this, AGE_REFRESH_MS) + } + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View? { + return inflater.inflate(R.layout.fragment_contacts, container, false) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + viewModel = ViewModelProvider(requireActivity())[ContactsViewModel::class.java] + + recyclerView = view.findViewById(R.id.contacts_recycler_view) + emptyState = view.findViewById(R.id.empty_state) + + emptyTitle = view.findViewById(R.id.empty_title) + emptyHint = view.findViewById(R.id.empty_hint) + searchInput = view.findViewById(R.id.search_input) + + adapter = ContactListAdapter().apply { + onItemClick = { contact -> openContact(contact.callsign) } + onStarClick = { contact -> viewModel.setStarred(contact.callsign, !contact.starred) } + } + recyclerView.adapter = adapter + + view.findViewById(R.id.network_map_button).setOnClickListener { + findNavController().navigate(R.id.navigation_network_map) + } + + searchInput.setText(viewModel.query.value.orEmpty()) + searchInput.addTextChangedListener( + afterTextChanged = { viewModel.setQuery(it?.toString().orEmpty()) } + ) + + viewModel.visibleContacts.observe(viewLifecycleOwner) { contacts -> + adapter.submitList(contacts) + val empty = contacts.isEmpty() + emptyState.visibility = if (empty) View.VISIBLE else View.GONE + recyclerView.visibility = if (empty) View.GONE else View.VISIBLE + if (empty) showEmptyState() + } + } + + /** + * An empty list means two different things. Nothing heard yet is the + * normal starting state; nothing matching a search is the operator's + * own doing and needs to say so. + */ + private fun showEmptyState() { + val query = viewModel.query.value.orEmpty().trim() + if (query.isEmpty()) { + emptyTitle.setText(R.string.contacts_empty) + emptyHint.setText(R.string.contacts_empty_hint) + } else { + emptyTitle.setText(R.string.contact_search_hint) + emptyHint.text = getString(R.string.contacts_no_matches, query) + } + } + + private fun openContact(callsign: String) { + findNavController().navigate( + R.id.navigation_contact_detail, + Bundle().apply { putString("callsign", callsign) } + ) + } + + override fun onStart() { + super.onStart() + ageHandler.postDelayed(ageTick, AGE_REFRESH_MS) + } + + override fun onStop() { + ageHandler.removeCallbacks(ageTick) + super.onStop() + } + + companion object { + private const val AGE_REFRESH_MS = 30_000L + } +} diff --git a/android/app/src/main/java/com/js8call/example/ui/ContactsViewModel.kt b/android/app/src/main/java/com/js8call/example/ui/ContactsViewModel.kt new file mode 100644 index 000000000..9cdec6bfb --- /dev/null +++ b/android/app/src/main/java/com/js8call/example/ui/ContactsViewModel.kt @@ -0,0 +1,65 @@ +package com.js8call.example.ui + +import android.app.Application +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.LiveData +import androidx.lifecycle.MediatorLiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.viewModelScope +import com.js8call.example.data.ContactEntity +import com.js8call.example.data.ContactRepository +import com.js8call.example.data.MailboxRepository +import com.js8call.example.util.ContactSearch +import kotlinx.coroutines.launch + +/** + * ViewModel for the Contacts screen: stations heard on the air, + * starred first, then most recent. + */ +class ContactsViewModel(application: Application) : AndroidViewModel(application) { + + private val repository = ContactRepository.getInstance(application) + private val mailboxRepository = MailboxRepository(application) + + val contacts: LiveData> = repository.getContacts() + + private val _query = MutableLiveData("") + val query: LiveData = _query + + /** The list the contacts screen shows: everything, or what matches. */ + val visibleContacts: LiveData> = + MediatorLiveData>().apply { + fun refresh() { + value = ContactSearch.filter(contacts.value.orEmpty(), _query.value.orEmpty()) + } + addSource(contacts) { refresh() } + addSource(_query) { refresh() } + } + + fun setQuery(text: String) { + if (_query.value == text) return + _query.value = text + } + + fun getContact(callsign: String): LiveData = + repository.getContactLive(callsign) + + fun getHeldMailCount(callsign: String): LiveData = + mailboxRepository.getHeldCountFor(callsign) + + fun setStarred(callsign: String, starred: Boolean) { + viewModelScope.launch { repository.setStarred(callsign, starred) } + } + + fun setComment(callsign: String, comment: String?) { + viewModelScope.launch { repository.setComment(callsign, comment) } + } + + fun setName(callsign: String, name: String?) { + viewModelScope.launch { repository.setName(callsign, name) } + } + + fun deleteContact(callsign: String) { + viewModelScope.launch { repository.deleteContact(callsign) } + } +} diff --git a/android/app/src/main/java/com/js8call/example/ui/ConversationFragment.kt b/android/app/src/main/java/com/js8call/example/ui/ConversationFragment.kt index 96d70a326..4fec19365 100644 --- a/android/app/src/main/java/com/js8call/example/ui/ConversationFragment.kt +++ b/android/app/src/main/java/com/js8call/example/ui/ConversationFragment.kt @@ -1,27 +1,29 @@ package com.js8call.example.ui -import android.content.BroadcastReceiver -import android.content.Context import android.content.Intent -import android.content.IntentFilter import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup -import android.view.inputmethod.EditorInfo import android.widget.LinearLayout +import android.widget.TextView import androidx.fragment.app.Fragment import androidx.lifecycle.ViewModelProvider import androidx.localbroadcastmanager.content.LocalBroadcastManager +import androidx.navigation.fragment.findNavController +import androidx.preference.PreferenceManager import androidx.recyclerview.widget.RecyclerView -import com.google.android.material.floatingactionbutton.FloatingActionButton -import com.google.android.material.textfield.TextInputEditText -import com.google.android.material.textfield.TextInputLayout +import com.google.android.material.appbar.MaterialToolbar +import com.google.android.material.snackbar.Snackbar +import com.js8call.example.MainActivity import com.js8call.example.R -import com.js8call.example.service.JS8EngineService +import com.js8call.example.model.TransmitState +import com.js8call.example.util.AvatarColor +import com.js8call.example.util.DisplayName +import com.js8call.example.util.RelayPath /** - * Fragment showing a single conversation with chat bubbles. + * A direct-message thread with one station. */ class ConversationFragment : Fragment() { @@ -31,29 +33,13 @@ class ConversationFragment : Fragment() { private lateinit var recyclerView: RecyclerView private lateinit var emptyState: LinearLayout - private lateinit var messageInputLayout: TextInputLayout - private lateinit var messageInput: TextInputEditText - private lateinit var sendButton: FloatingActionButton + private lateinit var relayStrip: View + private lateinit var relayLabel: TextView private var callsign: String = "" - // Track the last sent message ID for status updates - private var lastSentMessageId: Long? = null - - private val txStateReceiver = object : BroadcastReceiver() { - override fun onReceive(context: Context?, intent: Intent?) { - when (intent?.action) { - JS8EngineService.ACTION_TX_STATE -> { - val state = intent.getStringExtra(JS8EngineService.EXTRA_TX_STATE) - if (state == JS8EngineService.TX_STATE_FINISHED) { - lastSentMessageId?.let { id -> - viewModel.updateMessageStatus(id, com.js8call.example.data.MessageEntity.STATUS_SENT) - } - } - } - } - } - } + /** Empty means transmit direct. Kept in sync by the stored-path observer. */ + private var relayHops: List = emptyList() override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -71,61 +57,104 @@ class ConversationFragment : Fragment() { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) - // Set title to callsign - activity?.title = callsign - - // Initialize ViewModels viewModel = ViewModelProvider(requireActivity())[MessagesViewModel::class.java] transmitViewModel = ViewModelProvider(requireActivity())[TransmitViewModel::class.java] - // Find views - recyclerView = view.findViewById(R.id.messages_recycler_view) - emptyState = view.findViewById(R.id.empty_state) - messageInputLayout = view.findViewById(R.id.message_input_layout) - messageInput = view.findViewById(R.id.message_input) - sendButton = view.findViewById(R.id.send_button) - - // Set up RecyclerView - adapter = MessageBubbleAdapter().apply { - onMessageLongClick = { message -> - // Could show options to copy/delete - true + val toolbar = view.findViewById(R.id.thread_toolbar) + toolbar.setNavigationOnClickListener { findNavController().navigateUp() } + SpeedChip.bind(view.findViewById(R.id.speed_chip)) + bindThreadHeader(view) + + // Mailbox actions make sense toward a station, not a group. A group + // thread the operator has not joined gets a Join action instead. + if (!callsign.startsWith("@")) { + toolbar.inflateMenu(R.menu.conversation_menu) + toolbar.setOnMenuItemClickListener { item -> + when (item.itemId) { + R.id.action_relay_path -> { + openRelayPathEditor() + true + } + R.id.action_check_messages -> { + // Any mail waiting for us at this station? + sendMessage("QUERY MSGS") + true + } + R.id.action_send_via_relay -> { + showSendViaRelayDialog() + true + } + else -> false + } + } + } else if (!isSubscribedGroup()) { + toolbar.inflateMenu(R.menu.group_menu) + toolbar.setOnMenuItemClickListener { item -> + when (item.itemId) { + R.id.action_join_group -> { + joinGroup() + toolbar.menu.removeItem(R.id.action_join_group) + true + } + else -> false + } } } - recyclerView.adapter = adapter - // Register adapter data observer to scroll to bottom on new messages - adapter.registerAdapterDataObserver(object : RecyclerView.AdapterDataObserver() { - override fun onItemRangeInserted(positionStart: Int, itemCount: Int) { - recyclerView.scrollToPosition(adapter.itemCount - 1) + // The protocol will not relay to a group, so a group thread gets no + // path strip at all rather than one that cannot be used. + relayStrip = view.findViewById(R.id.relay_path_strip) + relayLabel = view.findViewById(R.id.relay_path_label) + val relayDivider = view.findViewById(R.id.relay_path_divider) + if (callsign.startsWith("@")) { + relayStrip.visibility = View.GONE + relayDivider.visibility = View.GONE + } else { + relayStrip.setOnClickListener { openRelayPathEditor() } + viewModel.getRelayPath(callsign).observe(viewLifecycleOwner) { stored -> + relayHops = RelayPath.parse(stored) + relayLabel.text = describePath(relayHops) } - }) - - // Set up send button - sendButton.setOnClickListener { - sendMessage() } - // Set up keyboard send action - messageInput.setOnEditorActionListener { _, actionId, _ -> - if (actionId == EditorInfo.IME_ACTION_SEND) { - sendMessage() - true + recyclerView = view.findViewById(R.id.messages_recycler_view) + emptyState = view.findViewById(R.id.empty_state) + + adapter = MessageBubbleAdapter() + adapter.onSenderClick = { sender -> openContactCard(sender) } + recyclerView.adapter = adapter + // No cross-fade on rebinds; the sending label ticks every second + (recyclerView.itemAnimator as? androidx.recyclerview.widget.SimpleItemAnimator) + ?.supportsChangeAnimations = false + + ComposeBarController( + root = view, + // Per-station queries are meaningless aimed at a group + commandMenuRes = if (callsign.startsWith("@")) { + R.menu.group_command_menu } else { - false - } - } + R.menu.directed_command_menu + }, + onSend = { text -> sendMessage(text) }, + // Directed queries ride the normal send path, so they get + // bubble states and TX tracking like any other message. + onCommand = { text -> sendMessage(text) } + ) // Observe messages for this conversation viewModel.getMessagesForConversation(callsign).observe(viewLifecycleOwner) { messages -> + val wasAtBottom = !recyclerView.canScrollVertically(1) + val firstLoad = adapter.itemCount == 0 adapter.submitList(messages) { - // Scroll to bottom after list update - if (messages.isNotEmpty()) { - recyclerView.scrollToPosition(messages.size - 1) + if (messages.isNotEmpty() && (wasAtBottom || firstLoad)) { + if (firstLoad) { + recyclerView.scrollToPosition(messages.size - 1) + } else { + recyclerView.smoothScrollToPosition(messages.size - 1) + } } } - // Show/hide empty state if (messages.isEmpty()) { emptyState.visibility = View.VISIBLE recyclerView.visibility = View.GONE @@ -135,50 +164,214 @@ class ConversationFragment : Fragment() { } } + // The sending bubble: the queue head while transmitting, with the + // frame countdown and progress from the TransmitViewModel. + transmitViewModel.txState.observe(viewLifecycleOwner) { updateSendingBubble() } + transmitViewModel.queue.observe(viewLifecycleOwner) { updateSendingBubble() } + transmitViewModel.txCountdownSeconds.observe(viewLifecycleOwner) { updateSendingBubble() } + transmitViewModel.txFrameProgress.observe(viewLifecycleOwner) { updateSendingBubble() } + // Mark conversation as read when viewing viewModel.markConversationAsRead(callsign) } override fun onResume() { super.onResume() - // Register for TX state updates - val filter = IntentFilter().apply { - addAction(JS8EngineService.ACTION_TX_STATE) - } - LocalBroadcastManager.getInstance(requireContext()) - .registerReceiver(txStateReceiver, filter) - // Mark as read again in case new messages arrived viewModel.markConversationAsRead(callsign) } - override fun onPause() { - super.onPause() - LocalBroadcastManager.getInstance(requireContext()) - .unregisterReceiver(txStateReceiver) + private fun updateSendingBubble() { + val countdown = transmitViewModel.txCountdownSeconds.value + val progress = transmitViewModel.txFrameProgress.value + val sending = transmitViewModel.txState.value == TransmitState.TRANSMITTING || + progress != null + val activeDbId = if (sending) transmitViewModel.getNextMessage()?.dbId else null + val label = when { + !sending || countdown == null -> null + progress != null && progress.second > 1 -> + getString(R.string.msg_status_sending_frames, progress.first, progress.second, countdown) + else -> getString(R.string.msg_status_sending, countdown) + } + + val previousId = adapter.sendingMessageId + if (previousId == activeDbId && label == adapter.sendingLabel) return + adapter.sendingMessageId = activeDbId + adapter.sendingLabel = label + + val list = adapter.currentList + for (id in listOfNotNull(previousId, activeDbId).distinct()) { + val index = list.indexOfFirst { it.id == id } + if (index >= 0) adapter.notifyItemChanged(index, MessageBubbleAdapter.PAYLOAD_STATUS) + } + } + + /** + * The one way out of this thread. The compose bar, the command menu and + * "Check for messages" all land here, so routing this covers every + * transmission the thread makes. + */ + private fun sendMessage(text: String) { + if (!hasCallsignConfigured()) { + Snackbar.make(requireView(), R.string.error_callsign_required, Snackbar.LENGTH_LONG).show() + return + } + + val hops = relayHops + val storedPath = RelayPath.format(hops) + + viewModel.insertOutgoingMessage(callsign, text, relayPath = storedPath) + .observe(viewLifecycleOwner) { messageId -> + if (hops.isEmpty()) { + transmitViewModel.queueMessage(text, directed = callsign, dbId = messageId) + } else { + // A relay carries the destination in its payload, so the + // nearest hop becomes the directed target and the native + // packer reads it off the front of the text. + transmitViewModel.queueMessage( + RelayPath.compose(hops, callsign, text), + directed = null, + dbId = messageId + ) + } + LocalBroadcastManager.getInstance(requireContext()) + .sendBroadcast(Intent(MainActivity.ACTION_PROCESS_TX_QUEUE)) + } + } + + /** + * The toolbar names who the thread is with and opens their contact card. + * A group has no contact record, so it stays a plain heading. + */ + private fun bindThreadHeader(view: View) { + val header = view.findViewById(R.id.thread_header) + val avatarFrame = view.findViewById(R.id.avatar_frame) + val avatarText = view.findViewById(R.id.avatar_text) + val nameText = view.findViewById(R.id.thread_name) + val callsignText = view.findViewById(R.id.thread_callsign) + val isGroup = callsign.startsWith("@") + + avatarFrame.backgroundTintList = android.content.res.ColorStateList.valueOf( + androidx.core.content.ContextCompat.getColor( + requireContext(), AvatarColor.forCallsign(callsign) + ) + ) + avatarText.text = if (isGroup) "@" else DisplayName.initial(callsign, null) + nameText.text = callsign + + if (isGroup) { + header.isClickable = false + header.background = null + return + } + + header.setOnClickListener { openContactCard(callsign) } + + val contactsViewModel = ViewModelProvider(requireActivity())[ContactsViewModel::class.java] + contactsViewModel.getContact(callsign).observe(viewLifecycleOwner) { contact -> + val name = contact?.name + nameText.text = DisplayName.of(callsign, name) + avatarText.text = DisplayName.initial(callsign, name) + val secondary = DisplayName.secondary(callsign, name) + callsignText.text = secondary.orEmpty() + callsignText.visibility = if (secondary == null) View.GONE else View.VISIBLE + } + } + + private fun openContactCard(station: String) { + findNavController().navigate( + R.id.navigation_contact_detail, + Bundle().apply { putString("callsign", station) } + ) } - private fun sendMessage() { - val text = messageInput.text?.toString()?.trim() ?: return - if (text.isEmpty()) return - - // Clear input - messageInput.text?.clear() - - // Insert outgoing message to database - viewModel.insertOutgoingMessage(callsign, text).observe(viewLifecycleOwner) { messageId -> - lastSentMessageId = messageId - - // Queue the message for transmission via the TX queue - // Format: MSG text, directed to callsign - // The engine will prepend the callsign when selectedCall is set - val fullMessage = "MSG $text" - transmitViewModel.queueMessage(fullMessage, callsign, priority = 0, clearComposed = false) - - // Trigger queue processing - LocalBroadcastManager.getInstance(requireContext()).sendBroadcast( - Intent(com.js8call.example.MainActivity.ACTION_PROCESS_TX_QUEUE) + private fun openRelayPathEditor() { + findNavController().navigate( + R.id.action_conversation_to_relay_path, + Bundle().apply { putString("callsign", callsign) } + ) + } + + /** "Direct", or the hop count followed by the stations in transmit order. */ + private fun describePath(hops: List): String { + if (hops.isEmpty()) return getString(R.string.relay_direct) + val count = resources.getQuantityString(R.plurals.relay_hop_count, hops.size, hops.size) + return "$count · " + hops.joinToString(" › ") + } + + private fun hasCallsignConfigured(): Boolean { + val prefs = PreferenceManager.getDefaultSharedPreferences(requireContext()) + return prefs.getString("callsign", "")?.isNotBlank() == true + } + + private fun isSubscribedGroup(): Boolean { + val prefs = PreferenceManager.getDefaultSharedPreferences(requireContext()) + val groups = (prefs.getString("my_groups", "") ?: "") + .split(",").map { it.trim().uppercase() }.filter { it.isNotEmpty() } + return callsign.uppercase() in groups + } + + private fun joinGroup() { + val prefs = PreferenceManager.getDefaultSharedPreferences(requireContext()) + val groups = (prefs.getString("my_groups", "") ?: "") + .split(",").map { it.trim().uppercase() }.filter { it.isNotEmpty() } + if (callsign.uppercase() !in groups) { + prefs.edit() + .putString("my_groups", (groups + callsign.uppercase()).joinToString(",")) + .apply() + } + Snackbar.make(requireView(), getString(R.string.group_joined, callsign), Snackbar.LENGTH_SHORT) + .show() + } + + /** + * Deposit a message for this station at a third station's mailbox: + * MY: RELAY MSG TO:DEST text. The thread row stays under this station, + * because the conversation is with the person, not the hop. + */ + private fun showSendViaRelayDialog() { + val dialogView = layoutInflater.inflate(R.layout.dialog_send_via_relay, null) + val relayInput = dialogView.findViewById< + com.google.android.material.textfield.MaterialAutoCompleteTextView>(R.id.relay_input) + val messageInput = dialogView.findViewById< + com.google.android.material.textfield.TextInputEditText>(R.id.relay_message_input) + + val decodeViewModel = ViewModelProvider(requireActivity())[DecodeViewModel::class.java] + val heard = decodeViewModel.heardCallsigns().filterNot { it.equals(callsign, true) } + relayInput.setAdapter( + android.widget.ArrayAdapter( + requireContext(), android.R.layout.simple_list_item_1, heard ) + ) + relayInput.threshold = 1 + + com.google.android.material.dialog.MaterialAlertDialogBuilder(requireContext()) + .setTitle(R.string.conversation_send_via_relay) + .setView(dialogView) + .setPositiveButton(R.string.relay_send) { _, _ -> + val relay = relayInput.text?.toString()?.trim()?.uppercase().orEmpty() + val text = messageInput.text?.toString()?.trim()?.uppercase().orEmpty() + if (relay.isEmpty() || text.isEmpty()) return@setPositiveButton + sendViaRelay(relay, text) + } + .setNegativeButton(android.R.string.cancel, null) + .show() + } + + private fun sendViaRelay(relay: String, text: String) { + if (!hasCallsignConfigured()) { + Snackbar.make(requireView(), R.string.error_callsign_required, Snackbar.LENGTH_LONG).show() + return } + viewModel.insertOutgoingMessage(callsign, text, relayPath = relay) + .observe(viewLifecycleOwner) { messageId -> + // The engine prefixes "MY: RELAY" and appends the checksum + // the buffered MSG TO: command requires. + transmitViewModel.queueMessage( + "MSG TO:$callsign $text", directed = relay, dbId = messageId + ) + LocalBroadcastManager.getInstance(requireContext()) + .sendBroadcast(Intent(MainActivity.ACTION_PROCESS_TX_QUEUE)) + } } } diff --git a/android/app/src/main/java/com/js8call/example/ui/ConversationListAdapter.kt b/android/app/src/main/java/com/js8call/example/ui/ConversationListAdapter.kt index 146026b19..2abfe2bdf 100644 --- a/android/app/src/main/java/com/js8call/example/ui/ConversationListAdapter.kt +++ b/android/app/src/main/java/com/js8call/example/ui/ConversationListAdapter.kt @@ -1,15 +1,19 @@ package com.js8call.example.ui +import android.content.res.ColorStateList import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.widget.FrameLayout import android.widget.TextView +import androidx.core.content.ContextCompat import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.ListAdapter import androidx.recyclerview.widget.RecyclerView import com.js8call.example.R import com.js8call.example.data.ConversationSummary +import com.js8call.example.util.AvatarColor +import com.js8call.example.util.DisplayName import java.text.SimpleDateFormat import java.util.* @@ -23,6 +27,18 @@ class ConversationListAdapter : ListAdapter Unit)? = null var onItemLongClick: ((ConversationSummary) -> Boolean)? = null + /** + * Callsign to name, for the rows that have one. Conversations come from + * the messages table and names from contacts, and the two are joined + * here rather than in SQL so the conversation query stays as it is. + */ + var names: Map = emptyMap() + set(value) { + if (field == value) return + field = value + notifyItemRangeChanged(0, itemCount) + } + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ConversationViewHolder { val view = LayoutInflater.from(parent.context) .inflate(R.layout.item_conversation, parent, false) @@ -31,7 +47,7 @@ class ConversationListAdapter : ListAdapter = emptySet() + // Row that currently carries the live TX label, so it can be cleared + // when a newer outgoing bubble arrives. + private var labeledPosition = -1 + override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View? { - return inflater.inflate(R.layout.fragment_decodes, container, false) + return inflater.inflate(layoutRes, container, false) } override fun onStart() { @@ -79,15 +83,48 @@ class DecodeFragment : Fragment() { } } recyclerView.adapter = adapter + // Pin content to the bottom, texting style + (recyclerView.layoutManager as? androidx.recyclerview.widget.LinearLayoutManager)?.stackFromEnd = true + // No cross-fade on rebinds; the TX label ticks every second + (recyclerView.itemAnimator as? androidx.recyclerview.widget.SimpleItemAnimator) + ?.supportsChangeAnimations = false - // Set up FAB - clearFab.setOnClickListener { + // Set up the clear button (absent in the bare layout) + clearFab?.setOnClickListener { confirmClearDecodes() } + // Header quick actions (also absent in the bare layout; the All + // activity thread has its own compose bar for these) + view.findViewById(R.id.cq_button)?.setOnClickListener { + queueBroadcast("CQ CQ CQ") + } + view.findViewById(R.id.heartbeat_button)?.setOnClickListener { + queueBroadcast("HB") + } + view.findViewById(R.id.open_all_activity_button)?.setOnClickListener { + findNavController().navigate(R.id.navigation_everything) + } + // Observe decodes viewModel.decodes.observe(viewLifecycleOwner) { decodes -> - adapter.submitList(decodes) + // Stick to the newest message unless the user scrolled up to read history. + val wasAtBottom = !recyclerView.canScrollVertically(1) + val firstLoad = adapter.itemCount == 0 + + // submitList diffs on a background thread; scroll in its commit + // callback so the new row exists when the scroll runs. + adapter.submitList(decodes) { + // The newest-outgoing position may have shifted; move the TX label with it + updateTxLabel() + if (decodes.isNotEmpty() && (wasAtBottom || firstLoad)) { + if (firstLoad) { + recyclerView.scrollToPosition(decodes.size - 1) + } else { + recyclerView.smoothScrollToPosition(decodes.size - 1) + } + } + } // Show/hide empty state if (decodes.isEmpty()) { @@ -96,11 +133,36 @@ class DecodeFragment : Fragment() { } else { emptyText.visibility = View.GONE recyclerView.visibility = View.VISIBLE - - // Auto-scroll to top for new messages - recyclerView.scrollToPosition(0) } } + + // TX frame countdown and progress on the newest outgoing bubble + transmitViewModel.txCountdownSeconds.observe(viewLifecycleOwner) { updateTxLabel() } + transmitViewModel.txFrameProgress.observe(viewLifecycleOwner) { updateTxLabel() } + } + + private fun updateTxLabel() { + val countdown = transmitViewModel.txCountdownSeconds.value + val progress = transmitViewModel.txFrameProgress.value + val label = when { + countdown == null -> null + progress != null && progress.second > 1 -> + getString(R.string.decodes_tx_countdown_frames, progress.first, progress.second, countdown) + else -> getString(R.string.decodes_tx_countdown, countdown) + } + val position = adapter.lastOutgoingPosition() + if (label == adapter.txLabel && position == labeledPosition) return + adapter.txLabel = label + // Clear the label off the bubble that used to be newest + if (labeledPosition >= 0 && labeledPosition != position && + labeledPosition < adapter.itemCount + ) { + adapter.notifyItemChanged(labeledPosition, DecodeListAdapter.PAYLOAD_TX_LABEL) + } + if (position >= 0) { + adapter.notifyItemChanged(position, DecodeListAdapter.PAYLOAD_TX_LABEL) + } + labeledPosition = if (label != null) position else -1 } private fun showDecodeOptions(decode: com.js8call.example.model.DecodedMessage) { @@ -149,13 +211,9 @@ class DecodeFragment : Fragment() { Snackbar.make(requireView(), "No callsign found", Snackbar.LENGTH_SHORT).show() return } - transmitViewModel.setDirectedTo(callsign) - val bottomNav = activity?.findViewById(R.id.bottom_navigation) - if (bottomNav != null) { - bottomNav.selectedItemId = R.id.navigation_transmit - } else { - findNavController().navigate(R.id.navigation_transmit) - } + // Open the DM thread with this station + val bundle = Bundle().apply { putString("callsign", callsign) } + findNavController().navigate(R.id.navigation_conversation, bundle) } private fun extractCallsign(text: String): String? { @@ -179,6 +237,23 @@ class DecodeFragment : Fragment() { return true } + private fun queueBroadcast(text: String) { + val monitorViewModel = ViewModelProvider(requireActivity())[MonitorViewModel::class.java] + if (monitorViewModel.status.value?.state != com.js8call.example.model.EngineState.RUNNING) { + Snackbar.make(requireView(), R.string.decodes_start_first, Snackbar.LENGTH_SHORT).show() + return + } + val prefs = androidx.preference.PreferenceManager.getDefaultSharedPreferences(requireContext()) + if (prefs.getString("callsign", "")?.isNotBlank() != true) { + Snackbar.make(requireView(), R.string.error_callsign_required, Snackbar.LENGTH_LONG).show() + return + } + transmitViewModel.queueMessage(text, directed = null, priority = 1) + androidx.localbroadcastmanager.content.LocalBroadcastManager.getInstance(requireContext()) + .sendBroadcast(android.content.Intent(com.js8call.example.MainActivity.ACTION_PROCESS_TX_QUEUE)) + Snackbar.make(requireView(), getString(R.string.decodes_queued, text), Snackbar.LENGTH_SHORT).show() + } + private fun confirmClearDecodes() { MaterialAlertDialogBuilder(requireContext()) .setTitle("Clear All Decodes?") diff --git a/android/app/src/main/java/com/js8call/example/ui/DecodeListAdapter.kt b/android/app/src/main/java/com/js8call/example/ui/DecodeListAdapter.kt index dface3c91..b3f7f394a 100644 --- a/android/app/src/main/java/com/js8call/example/ui/DecodeListAdapter.kt +++ b/android/app/src/main/java/com/js8call/example/ui/DecodeListAdapter.kt @@ -16,11 +16,37 @@ import com.js8call.example.model.DecodedMessage */ class DecodeListAdapter : ListAdapter(DecodeDiffCallback()) { + companion object { + /** Partial-bind payload: update only the TX label, no full rebind. */ + val PAYLOAD_TX_LABEL = Any() + } + var myGroups: Set = emptySet() + /** Live TX label for the newest outgoing bubble, e.g. "TX 1/2 · 12s". */ + var txLabel: String? = null + var onItemClick: ((DecodedMessage) -> Unit)? = null var onItemLongClick: ((DecodedMessage) -> Boolean)? = null + fun lastOutgoingPosition(): Int = currentList.indexOfLast { it.outgoing } + + override fun onBindViewHolder( + holder: DecodeViewHolder, + position: Int, + payloads: MutableList + ) { + if (payloads.contains(PAYLOAD_TX_LABEL)) { + holder.bindTxLabel(labelAt(position)) + return + } + super.onBindViewHolder(holder, position, payloads) + } + + private fun labelAt(position: Int): String? { + return if (position == lastOutgoingPosition()) txLabel else null + } + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): DecodeViewHolder { val view = LayoutInflater.from(parent.context) .inflate(R.layout.item_decode, parent, false) @@ -29,7 +55,7 @@ class DecodeListAdapter : ListAdapter) { - // Set SNR indicator color - val color = ContextCompat.getColor(itemView.context, decode.snrColorRes) - snrIndicator.setBackgroundColor(color) + fun bindTxLabel(label: String?) { + if (label != null) { + snrText.text = label + } else { + snrText.text = itemView.context.getString(R.string.decodes_outgoing_tag) + } + } - // Set text values + fun bind(decode: DecodedMessage, myGroups: Set, txLabel: String? = null) { + val context = itemView.context timeText.text = decode.formattedTime() - snrText.text = String.format("%+d dB", decode.snr) - dtText.text = String.format("%+.1f s", decode.dt) freqText.text = String.format("%.1f Hz", decode.frequency) messageText.text = decode.text + // Received on the left, own transmissions on the right, texting style + val params = bubbleContainer.layoutParams + as androidx.constraintlayout.widget.ConstraintLayout.LayoutParams + params.horizontalBias = if (decode.outgoing) 1f else 0f + bubbleContainer.layoutParams = params + + if (decode.outgoing) { + bubbleContainer.setBackgroundResource(R.drawable.bubble_outgoing) + val textColor = ContextCompat.getColor(context, R.color.bubble_text_outgoing) + messageText.setTextColor(textColor) + timeText.setTextColor(textColor) + snrText.setTextColor(textColor) + freqText.setTextColor(textColor) + bindTxLabel(txLabel) + snrIndicator.visibility = View.GONE + dtText.visibility = View.GONE + return + } + + bubbleContainer.setBackgroundResource(R.drawable.bubble_incoming) + val textColor = ContextCompat.getColor(context, R.color.bubble_text_incoming) + timeText.setTextColor(textColor) + snrText.setTextColor(textColor) + dtText.setTextColor(textColor) + freqText.setTextColor(textColor) + + snrIndicator.visibility = View.VISIBLE + snrIndicator.background.mutate().setTint(ContextCompat.getColor(context, decode.snrColorRes)) + snrText.text = String.format("%+d dB", decode.snr) + dtText.visibility = View.VISIBLE + dtText.text = String.format("%+.1f s", decode.dt) + // Highlight group messages val isGroupMsg = myGroups.any { it.isNotEmpty() && decode.text.contains(it, ignoreCase = true) } - if (isGroupMsg) { - messageText.setTextColor(ContextCompat.getColor(itemView.context, R.color.highlight_group)) - } else { - messageText.setTextColor(defaultTextColor) - } + messageText.setTextColor( + if (isGroupMsg) ContextCompat.getColor(context, R.color.highlight_group) else textColor + ) } } diff --git a/android/app/src/main/java/com/js8call/example/ui/DecodeViewModel.kt b/android/app/src/main/java/com/js8call/example/ui/DecodeViewModel.kt index 1c8bf36ec..5038ca051 100644 --- a/android/app/src/main/java/com/js8call/example/ui/DecodeViewModel.kt +++ b/android/app/src/main/java/com/js8call/example/ui/DecodeViewModel.kt @@ -5,9 +5,12 @@ import android.util.Log import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.viewModelScope import androidx.preference.PreferenceManager +import com.js8call.example.data.ContactRepository import com.js8call.example.model.DecodedMessage import com.js8call.example.model.MessageBuffer +import kotlinx.coroutines.launch import java.io.File import kotlin.math.abs import kotlin.math.roundToInt @@ -85,11 +88,11 @@ class DecodeViewModel(application: Application) : AndroidViewModel(application) * Add a new decoded message. */ fun addDecode(message: DecodedMessage) { - allDecodes.add(0, message) // Add to beginning + allDecodes.add(message) // Newest at the end, like a texting thread - // Limit size + // Limit size by dropping the oldest if (allDecodes.size > maxDecodes) { - allDecodes.removeAt(allDecodes.size - 1) + allDecodes.removeAt(0) } applyFilter() @@ -130,7 +133,8 @@ class DecodeViewModel(application: Application) : AndroidViewModel(application) quality = obj.optDouble("quality").toFloat(), mode = obj.optInt("mode"), driftMs = obj.optInt("driftMs"), - timestamp = obj.optLong("timestamp", System.currentTimeMillis()) + timestamp = obj.optLong("timestamp", System.currentTimeMillis()), + outgoing = obj.optBoolean("outgoing") ) ) } @@ -141,7 +145,8 @@ class DecodeViewModel(application: Application) : AndroidViewModel(application) if (loaded.isEmpty()) return allDecodes.clear() - allDecodes.addAll(loaded.take(maxDecodes)) + // Sort oldest to newest so files saved before the newest-last change load correctly. + allDecodes.addAll(loaded.sortedBy { it.timestamp }.takeLast(maxDecodes)) applyFilter() } @@ -230,15 +235,56 @@ class DecodeViewModel(application: Application) : AndroidViewModel(application) } } + /** + * Add a message this station transmitted. It skips multipart buffering + * because the full text is known at submit time. + */ + fun addOutgoing(text: String, frequency: Float) { + if (text.isBlank()) return + val cal = java.util.Calendar.getInstance(java.util.TimeZone.getTimeZone("UTC")) + val utc = cal.get(java.util.Calendar.HOUR_OF_DAY) * 10000 + + cal.get(java.util.Calendar.MINUTE) * 100 + + cal.get(java.util.Calendar.SECOND) + addDecodeToDisplay( + DecodedMessage( + utc = utc, + snr = 0, + dt = 0f, + frequency = frequency, + text = text, + type = 3, + quality = 1f, + mode = 0, + outgoing = true + ) + ) + } + /** * Add a decoded message directly to the display list. */ private fun addDecodeToDisplay(message: DecodedMessage) { - allDecodes.add(0, message) // Add to beginning + // Every heard station lands in the contact list + if (!message.outgoing) { + val app = getApplication() + val myCallsign = PreferenceManager.getDefaultSharedPreferences(app) + .getString("callsign", null) + viewModelScope.launch { + ContactRepository.getInstance(app).recordDecode( + text = message.text, + snr = message.snr, + offsetHz = message.frequency, + timestamp = message.timestamp, + myCallsign = myCallsign + ) + } + } + + allDecodes.add(message) // Newest at the end, like a texting thread - // Limit size + // Limit size by dropping the oldest if (allDecodes.size > maxDecodes) { - allDecodes.removeAt(allDecodes.size - 1) + allDecodes.removeAt(0) } applyFilter() @@ -403,6 +449,7 @@ class DecodeViewModel(application: Application) : AndroidViewModel(application) obj.put("mode", decode.mode) obj.put("driftMs", decode.driftMs) obj.put("timestamp", decode.timestamp) + obj.put("outgoing", decode.outgoing) arr.put(obj) } @@ -415,9 +462,10 @@ class DecodeViewModel(application: Application) : AndroidViewModel(application) /** * Sender callsigns from the in-memory decode list, newest first. + * The list stores newest last, so iterate in reverse. */ fun heardCallsigns(): List { - return allDecodes.mapNotNull { senderCallsign(it.text) }.distinct() + return allDecodes.asReversed().mapNotNull { senderCallsign(it.text) }.distinct() } private fun senderCallsign(text: String): String? { diff --git a/android/app/src/main/java/com/js8call/example/ui/EverythingFragment.kt b/android/app/src/main/java/com/js8call/example/ui/EverythingFragment.kt new file mode 100644 index 000000000..c1cdd4869 --- /dev/null +++ b/android/app/src/main/java/com/js8call/example/ui/EverythingFragment.kt @@ -0,0 +1,79 @@ +package com.js8call.example.ui + +import android.content.Intent +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModelProvider +import androidx.localbroadcastmanager.content.LocalBroadcastManager +import androidx.navigation.fragment.findNavController +import androidx.preference.PreferenceManager +import com.google.android.material.appbar.MaterialToolbar +import com.google.android.material.snackbar.Snackbar +import com.js8call.example.MainActivity +import com.js8call.example.R + +/** + * The Everything thread: all band activity with a compose bar. + * Sends here are undirected (free text, CQ, Heartbeat). + */ +class EverythingFragment : Fragment() { + + private lateinit var transmitViewModel: TransmitViewModel + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View? { + return inflater.inflate(R.layout.fragment_everything, container, false) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + transmitViewModel = ViewModelProvider(requireActivity())[TransmitViewModel::class.java] + + view.findViewById(R.id.thread_toolbar).setNavigationOnClickListener { + findNavController().navigateUp() + } + SpeedChip.bind(view.findViewById(R.id.speed_chip)) + view.findViewById(R.id.clear_button).setOnClickListener { confirmClearDecodes() } + + ComposeBarController( + root = view, + commandMenuRes = R.menu.broadcast_command_menu, + onSend = { text -> queueBroadcast(text) }, + onCommand = { text -> queueBroadcast(text, priority = 1) } + ) + } + + private fun queueBroadcast(text: String, priority: Int = 0) { + if (!hasCallsignConfigured()) { + Snackbar.make(requireView(), R.string.error_callsign_required, Snackbar.LENGTH_LONG).show() + return + } + transmitViewModel.queueMessage(text, directed = null, priority = priority) + LocalBroadcastManager.getInstance(requireContext()) + .sendBroadcast(Intent(MainActivity.ACTION_PROCESS_TX_QUEUE)) + } + + private fun hasCallsignConfigured(): Boolean { + val prefs = PreferenceManager.getDefaultSharedPreferences(requireContext()) + return prefs.getString("callsign", "")?.isNotBlank() == true + } + + private fun confirmClearDecodes() { + com.google.android.material.dialog.MaterialAlertDialogBuilder(requireContext()) + .setTitle("Clear All Decodes?") + .setMessage("This will remove all decoded messages from the list.") + .setPositiveButton("Clear") { _, _ -> + ViewModelProvider(requireActivity())[DecodeViewModel::class.java].clearDecodes() + Snackbar.make(requireView(), "Decodes cleared", Snackbar.LENGTH_SHORT).show() + } + .setNegativeButton("Cancel", null) + .show() + } +} diff --git a/android/app/src/main/java/com/js8call/example/ui/MailboxFragment.kt b/android/app/src/main/java/com/js8call/example/ui/MailboxFragment.kt new file mode 100644 index 000000000..86b84b638 --- /dev/null +++ b/android/app/src/main/java/com/js8call/example/ui/MailboxFragment.kt @@ -0,0 +1,109 @@ +package com.js8call.example.ui + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.LinearLayout +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModelProvider +import androidx.navigation.fragment.findNavController +import androidx.recyclerview.widget.RecyclerView +import com.google.android.material.appbar.MaterialToolbar +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.google.android.material.snackbar.Snackbar +import com.js8call.example.R +import com.js8call.example.data.MailboxEntity + +/** + * Held messages: store-and-forward mail this station holds for other + * operators, grouped by destination. Not conversations — that is the + * point of the separate screen. + */ +class MailboxFragment : Fragment() { + + private lateinit var viewModel: MailboxViewModel + private lateinit var adapter: MailboxListAdapter + private lateinit var recyclerView: RecyclerView + private lateinit var emptyState: LinearLayout + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View? { + return inflater.inflate(R.layout.fragment_mailbox, container, false) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + viewModel = ViewModelProvider(requireActivity())[MailboxViewModel::class.java] + + val toolbar = view.findViewById(R.id.mailbox_toolbar) + toolbar.setNavigationOnClickListener { findNavController().navigateUp() } + toolbar.inflateMenu(R.menu.mailbox_menu) + toolbar.setOnMenuItemClickListener { item -> + when (item.itemId) { + R.id.action_delete_delivered -> { + confirmDeleteDelivered() + true + } + else -> false + } + } + + recyclerView = view.findViewById(R.id.mailbox_recycler_view) + emptyState = view.findViewById(R.id.empty_state) + + adapter = MailboxListAdapter().apply { + onMessageClick = { row -> showMessage(row) } + } + recyclerView.adapter = adapter + + viewModel.messages.observe(viewLifecycleOwner) { rows -> + adapter.submitList(MailboxListAdapter.buildRows(rows)) + emptyState.visibility = if (rows.isEmpty()) View.VISIBLE else View.GONE + recyclerView.visibility = if (rows.isEmpty()) View.GONE else View.VISIBLE + } + } + + private fun showMessage(row: MailboxViewModel.MailboxRow) { + val msg = row.message + val details = buildString { + append(getString(R.string.mailbox_detail_from, msg.originator)) + msg.relayPath?.let { append("\n").append(getString(R.string.mailbox_detail_via, it)) } + append("\n\n").append(msg.text) + } + MaterialAlertDialogBuilder(requireContext()) + .setTitle(msg.destination) + .setMessage(details) + .setPositiveButton(android.R.string.ok, null) + .setNegativeButton(R.string.mailbox_delete) { _, _ -> confirmDelete(msg) } + .show() + } + + private fun confirmDelete(msg: MailboxEntity) { + MaterialAlertDialogBuilder(requireContext()) + .setTitle(R.string.mailbox_delete) + .setMessage(getString(R.string.mailbox_delete_confirm, msg.destination)) + .setPositiveButton(android.R.string.ok) { _, _ -> + viewModel.delete(msg.id) + Snackbar.make(requireView(), R.string.mailbox_deleted, Snackbar.LENGTH_SHORT).show() + } + .setNegativeButton(android.R.string.cancel, null) + .show() + } + + private fun confirmDeleteDelivered() { + MaterialAlertDialogBuilder(requireContext()) + .setTitle(R.string.mailbox_delete_delivered) + .setMessage(R.string.mailbox_delete_delivered_confirm) + .setPositiveButton(android.R.string.ok) { _, _ -> + viewModel.deleteDelivered() + Snackbar.make(requireView(), R.string.mailbox_deleted, Snackbar.LENGTH_SHORT).show() + } + .setNegativeButton(android.R.string.cancel, null) + .show() + } +} diff --git a/android/app/src/main/java/com/js8call/example/ui/MailboxListAdapter.kt b/android/app/src/main/java/com/js8call/example/ui/MailboxListAdapter.kt new file mode 100644 index 000000000..8464dc1fe --- /dev/null +++ b/android/app/src/main/java/com/js8call/example/ui/MailboxListAdapter.kt @@ -0,0 +1,132 @@ +package com.js8call.example.ui + +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.TextView +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListAdapter +import androidx.recyclerview.widget.RecyclerView +import com.js8call.example.R +import com.js8call.example.data.MailboxEntity + +/** + * The Held messages list: a header row per destination, its messages below. + */ +class MailboxListAdapter : ListAdapter(DIFF) { + + var onMessageClick: ((MailboxViewModel.MailboxRow) -> Unit)? = null + + sealed class Row { + data class Header(val destination: String, val count: Int, val oldestAt: Long) : Row() + data class Message(val row: MailboxViewModel.MailboxRow) : Row() + } + + override fun getItemViewType(position: Int): Int = when (getItem(position)) { + is Row.Header -> TYPE_HEADER + is Row.Message -> TYPE_MESSAGE + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { + val inflater = LayoutInflater.from(parent.context) + return when (viewType) { + TYPE_HEADER -> HeaderHolder( + inflater.inflate(R.layout.item_mailbox_header, parent, false) + ) + else -> MessageHolder( + inflater.inflate(R.layout.item_mailbox_message, parent, false) + ) + } + } + + override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { + when (val row = getItem(position)) { + is Row.Header -> (holder as HeaderHolder).bind(row) + is Row.Message -> (holder as MessageHolder).bind(row.row) + } + } + + inner class HeaderHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { + private val destinationText: TextView = itemView.findViewById(R.id.destination_text) + private val summaryText: TextView = itemView.findViewById(R.id.summary_text) + + fun bind(header: Row.Header) { + destinationText.text = header.destination + summaryText.text = itemView.resources.getQuantityString( + R.plurals.mailbox_destination_summary, + header.count, header.count, formatAge(header.oldestAt) + ) + } + } + + inner class MessageHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { + private val originatorText: TextView = itemView.findViewById(R.id.originator_text) + private val stateText: TextView = itemView.findViewById(R.id.state_text) + private val previewText: TextView = itemView.findViewById(R.id.preview_text) + + fun bind(row: MailboxViewModel.MailboxRow) { + val msg = row.message + originatorText.text = itemView.resources.getString( + R.string.mailbox_from_age, msg.originator, formatAge(msg.receivedAt) + ) + previewText.text = msg.text + stateText.text = when { + msg.destination.startsWith("@") -> itemView.resources.getQuantityString( + R.plurals.mailbox_collected_by, row.collectedBy, row.collectedBy + ) + msg.state == MailboxEntity.STATE_DELIVERED -> + itemView.resources.getString(R.string.mailbox_state_delivered) + else -> itemView.resources.getString(R.string.mailbox_state_held) + } + itemView.setOnClickListener { onMessageClick?.invoke(row) } + } + } + + private fun formatAge(timestamp: Long): String { + val seconds = (System.currentTimeMillis() - timestamp) / 1000 + return when { + seconds < 60 -> "now" + seconds < 3600 -> "${seconds / 60}m" + seconds < 86400 -> "${seconds / 3600}h" + else -> "${seconds / 86400}d" + } + } + + companion object { + private const val TYPE_HEADER = 0 + private const val TYPE_MESSAGE = 1 + + /** Destinations by newest mail first, each destination's mail newest first. */ + fun buildRows(rows: List): List { + val byDestination = rows.groupBy { it.message.destination } + .entries + .sortedByDescending { entry -> entry.value.maxOf { it.message.receivedAt } } + val out = mutableListOf() + for ((destination, messages) in byDestination) { + out.add( + Row.Header( + destination, + messages.size, + messages.minOf { it.message.receivedAt } + ) + ) + messages.sortedByDescending { it.message.receivedAt } + .forEach { out.add(Row.Message(it)) } + } + return out + } + + private val DIFF = object : DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: Row, newItem: Row): Boolean = when { + oldItem is Row.Header && newItem is Row.Header -> + oldItem.destination == newItem.destination + oldItem is Row.Message && newItem is Row.Message -> + oldItem.row.message.id == newItem.row.message.id + else -> false + } + + override fun areContentsTheSame(oldItem: Row, newItem: Row): Boolean = + oldItem == newItem + } + } +} diff --git a/android/app/src/main/java/com/js8call/example/ui/MailboxViewModel.kt b/android/app/src/main/java/com/js8call/example/ui/MailboxViewModel.kt new file mode 100644 index 000000000..2c7574b5c --- /dev/null +++ b/android/app/src/main/java/com/js8call/example/ui/MailboxViewModel.kt @@ -0,0 +1,49 @@ +package com.js8call.example.ui + +import android.app.Application +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.LiveData +import androidx.lifecycle.MediatorLiveData +import androidx.lifecycle.viewModelScope +import com.js8call.example.data.MailboxEntity +import com.js8call.example.data.MailboxRepository +import kotlinx.coroutines.launch + +/** + * ViewModel for the Held messages screen: mail this station holds for + * other operators, grouped by destination. + */ +class MailboxViewModel(application: Application) : AndroidViewModel(application) { + + private val repository = MailboxRepository(application) + + /** Held count for the badge on the Messages header. */ + val heldCount: LiveData = repository.getHeldCount() + + /** + * All mailbox rows joined with per-message collection counts, ready + * for display. Recomputed when either source changes. + */ + val messages: LiveData> = MediatorLiveData>().apply { + val all = repository.getAll() + val counts = repository.getDeliveryCounts() + fun combine() { + val countById = counts.value.orEmpty().associate { it.msgId to it.count } + value = all.value.orEmpty().map { entity -> + MailboxRow(entity, countById[entity.id] ?: 0) + } + } + addSource(all) { combine() } + addSource(counts) { combine() } + } + + fun delete(id: Long) { + viewModelScope.launch { repository.delete(id) } + } + + fun deleteDelivered() { + viewModelScope.launch { repository.deleteDelivered() } + } + + data class MailboxRow(val message: MailboxEntity, val collectedBy: Int) +} diff --git a/android/app/src/main/java/com/js8call/example/ui/MessageBubbleAdapter.kt b/android/app/src/main/java/com/js8call/example/ui/MessageBubbleAdapter.kt index 99b244309..0c7d214cf 100644 --- a/android/app/src/main/java/com/js8call/example/ui/MessageBubbleAdapter.kt +++ b/android/app/src/main/java/com/js8call/example/ui/MessageBubbleAdapter.kt @@ -11,6 +11,7 @@ import androidx.recyclerview.widget.ListAdapter import androidx.recyclerview.widget.RecyclerView import com.js8call.example.R import com.js8call.example.data.MessageEntity +import com.js8call.example.util.RelayPath import java.text.SimpleDateFormat import java.util.* @@ -24,10 +25,22 @@ class MessageBubbleAdapter : ListAdapter companion object { private const val VIEW_TYPE_INCOMING = 0 private const val VIEW_TYPE_OUTGOING = 1 + + /** Partial-bind payload: update only the status line, no full rebind. */ + val PAYLOAD_STATUS = Any() } var onMessageLongClick: ((MessageEntity) -> Boolean)? = null + /** The sender's name on a group message opens their contact card. */ + var onSenderClick: ((String) -> Unit)? = null + + /** Message id of the bubble currently transmitting, if any. */ + var sendingMessageId: Long? = null + + /** Live status line for the sending bubble, e.g. "Sending 1/2 · 12s". */ + var sendingLabel: String? = null + override fun getItemViewType(position: Int): Int { return if (getItem(position).isIncoming()) VIEW_TYPE_INCOMING else VIEW_TYPE_OUTGOING } @@ -47,11 +60,24 @@ class MessageBubbleAdapter : ListAdapter } } + override fun onBindViewHolder( + holder: RecyclerView.ViewHolder, + position: Int, + payloads: MutableList + ) { + if (payloads.contains(PAYLOAD_STATUS) && holder is OutgoingMessageViewHolder) { + val message = getItem(position) + holder.bindStatus(message, labelFor(message)) + return + } + super.onBindViewHolder(holder, position, payloads) + } + override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { val message = getItem(position) when (holder) { - is IncomingMessageViewHolder -> holder.bind(message) - is OutgoingMessageViewHolder -> holder.bind(message) + is IncomingMessageViewHolder -> holder.bind(message, onSenderClick) + is OutgoingMessageViewHolder -> holder.bind(message, labelFor(message)) } holder.itemView.setOnLongClickListener { @@ -59,25 +85,34 @@ class MessageBubbleAdapter : ListAdapter } } + private fun labelFor(message: MessageEntity): String? { + return if (message.id == sendingMessageId) sendingLabel else null + } + + class IncomingMessageViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { private val senderText: TextView = itemView.findViewById(R.id.sender_text) private val messageText: TextView = itemView.findViewById(R.id.message_text) private val timestampText: TextView = itemView.findViewById(R.id.timestamp_text) private val snrText: TextView = itemView.findViewById(R.id.snr_text) + private val relayText: TextView = itemView.findViewById(R.id.relay_text) private val timeFormat = SimpleDateFormat("h:mm a", Locale.getDefault()) - fun bind(message: MessageEntity) { + fun bind(message: MessageEntity, onSenderClick: ((String) -> Unit)?) { messageText.text = message.text timestampText.text = timeFormat.format(Date(message.timestamp)) + bindRelayPath(relayText, message) // Show sender callsign for group conversations val sender = message.senderCallsign if (sender != null && message.conversationId.startsWith("@")) { senderText.visibility = View.VISIBLE senderText.text = sender + senderText.setOnClickListener { onSenderClick?.invoke(sender) } } else { senderText.visibility = View.GONE + senderText.setOnClickListener(null) } // Show SNR if available @@ -94,29 +129,34 @@ class MessageBubbleAdapter : ListAdapter private val messageText: TextView = itemView.findViewById(R.id.message_text) private val timestampText: TextView = itemView.findViewById(R.id.timestamp_text) private val statusIcon: ImageView = itemView.findViewById(R.id.status_icon) + private val statusText: TextView = itemView.findViewById(R.id.status_text) + private val relayText: TextView = itemView.findViewById(R.id.relay_text) private val timeFormat = SimpleDateFormat("h:mm a", Locale.getDefault()) - fun bind(message: MessageEntity) { + fun bind(message: MessageEntity, sendingLabel: String?) { messageText.text = message.text timestampText.text = timeFormat.format(Date(message.timestamp)) + bindStatus(message, sendingLabel) + + bindRelayPath(relayText, message) // Update status icon based on message status val (iconRes, tintColor) = when (message.status) { MessageEntity.STATUS_PENDING -> { - android.R.drawable.ic_menu_recent_history to R.color.message_pending + R.drawable.ic_schedule to R.color.message_pending } MessageEntity.STATUS_SENT -> { - android.R.drawable.ic_menu_send to R.color.message_sent + R.drawable.ic_check to R.color.message_sent } MessageEntity.STATUS_ACKED -> { - android.R.drawable.checkbox_on_background to R.color.message_acked + R.drawable.ic_done_all to R.color.message_acked } MessageEntity.STATUS_FAILED -> { - android.R.drawable.ic_delete to R.color.message_failed + R.drawable.ic_error_outline to R.color.message_failed } else -> { - android.R.drawable.ic_menu_send to R.color.message_sent + R.drawable.ic_check to R.color.message_sent } } @@ -126,6 +166,25 @@ class MessageBubbleAdapter : ListAdapter android.graphics.PorterDuff.Mode.SRC_IN ) } + + fun bindStatus(message: MessageEntity, sendingLabel: String?) { + val context = itemView.context + when { + sendingLabel != null -> { + statusText.visibility = View.VISIBLE + statusText.text = sendingLabel + } + message.isPending() -> { + statusText.visibility = View.VISIBLE + statusText.text = context.getString(R.string.msg_status_queued) + } + message.isFailed() -> { + statusText.visibility = View.VISIBLE + statusText.text = context.getString(R.string.messages_failed) + } + else -> statusText.visibility = View.GONE + } + } } private class MessageDiffCallback : DiffUtil.ItemCallback() { @@ -144,3 +203,18 @@ class MessageBubbleAdapter : ListAdapter } } } + +/** + * The stations that carried this message, shown on the message itself. The + * thread's path can change after the fact, so the bubble is the only honest + * record of how this one travelled. + */ +private fun bindRelayPath(view: TextView, message: MessageEntity) { + val hops = RelayPath.parse(message.relayPath) + if (hops.isEmpty()) { + view.visibility = View.GONE + } else { + view.visibility = View.VISIBLE + view.text = view.context.getString(R.string.relay_via, hops.joinToString(" › ")) + } +} diff --git a/android/app/src/main/java/com/js8call/example/ui/MessagesFragment.kt b/android/app/src/main/java/com/js8call/example/ui/MessagesFragment.kt index ae1594558..bbea37c78 100644 --- a/android/app/src/main/java/com/js8call/example/ui/MessagesFragment.kt +++ b/android/app/src/main/java/com/js8call/example/ui/MessagesFragment.kt @@ -4,6 +4,7 @@ import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import android.widget.ArrayAdapter import android.widget.LinearLayout import androidx.fragment.app.Fragment import androidx.lifecycle.ViewModelProvider @@ -12,20 +13,24 @@ import androidx.recyclerview.widget.RecyclerView import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.floatingactionbutton.FloatingActionButton import com.google.android.material.snackbar.Snackbar +import com.google.android.material.textfield.MaterialAutoCompleteTextView import com.js8call.example.R /** - * Fragment showing the list of conversations (Messages tab). + * The Messages tab: the pinned Everything thread plus DM conversations. */ class MessagesFragment : Fragment() { private lateinit var viewModel: MessagesViewModel + private lateinit var decodeViewModel: DecodeViewModel private lateinit var adapter: ConversationListAdapter private lateinit var recyclerView: RecyclerView private lateinit var emptyState: LinearLayout private lateinit var newMessageFab: FloatingActionButton + private var conversationCallsigns: List = emptyList() + override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, @@ -37,15 +42,40 @@ class MessagesFragment : Fragment() { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) - // Initialize ViewModel (shared with ConversationFragment) viewModel = ViewModelProvider(requireActivity())[MessagesViewModel::class.java] + decodeViewModel = ViewModelProvider(requireActivity())[DecodeViewModel::class.java] - // Find views recyclerView = view.findViewById(R.id.conversations_recycler_view) emptyState = view.findViewById(R.id.empty_state) newMessageFab = view.findViewById(R.id.new_message_fab) - // Set up RecyclerView + view.findViewById(R.id.everything_card).setOnClickListener { + findNavController().navigate(R.id.action_messages_to_everything) + } + + // Held messages entry: visible once the mailbox holds anything, + // badged with the count still waiting for its recipients. + val mailboxViewModel = ViewModelProvider(requireActivity())[MailboxViewModel::class.java] + val mailboxFrame = view.findViewById(R.id.mailbox_button_frame) + val mailboxBadge = view.findViewById(R.id.mailbox_badge) + view.findViewById(R.id.mailbox_button).setOnClickListener { + findNavController().navigate(R.id.action_messages_to_mailbox) + } + mailboxViewModel.messages.observe(viewLifecycleOwner) { rows -> + mailboxFrame.visibility = if (rows.isEmpty()) View.GONE else View.VISIBLE + } + mailboxViewModel.heldCount.observe(viewLifecycleOwner) { held -> + mailboxBadge.visibility = if (held > 0) View.VISIBLE else View.GONE + mailboxBadge.text = held.toString() + } + + // Newest band activity as the thread preview, like a DM row + val everythingPreview = view.findViewById(R.id.everything_preview) + decodeViewModel.decodes.observe(viewLifecycleOwner) { decodes -> + everythingPreview.text = decodes.lastOrNull()?.text + ?: getString(R.string.everything_subtitle) + } + adapter = ConversationListAdapter().apply { onItemClick = { conversation -> navigateToConversation(conversation.callsign) @@ -57,19 +87,48 @@ class MessagesFragment : Fragment() { } recyclerView.adapter = adapter - // Set up FAB (navigate to Transmit tab to compose new message) + // Threads come from the messages table and names from contacts. The + // two are joined here rather than in SQL, which leaves the + // conversation query alone. + ViewModelProvider(requireActivity())[ContactsViewModel::class.java] + .contacts.observe(viewLifecycleOwner) { contacts -> + adapter.names = contacts + .filter { !it.name.isNullOrBlank() } + .associate { it.callsign.uppercase() to it.name!!.trim() } + } + newMessageFab.setOnClickListener { - // Navigate to Transmit tab for new message - val bottomNav = activity?.findViewById(R.id.bottom_navigation) - bottomNav?.selectedItemId = R.id.navigation_transmit + showNewMessageDialog() + } + + val otherGroupsButton = view.findViewById(R.id.other_groups_button) + otherGroupsButton.setOnClickListener { + findNavController().navigate(R.id.action_messages_to_other_groups) } // Observe conversations viewModel.conversations.observe(viewLifecycleOwner) { conversations -> - adapter.submitList(conversations) + // Group traffic the operator has not subscribed to lives behind + // the Other groups entry, not in the main thread list. Groups + // they have subscribed to appear even before any traffic. + val subscribed = subscribedGroups() + val (other, mine) = conversations.partition { + it.callsign.startsWith("@") && it.callsign.uppercase() !in subscribed + } + val emptyGroups = subscribed + .filter { group -> mine.none { it.callsign.equals(group, true) } } + .map { + com.js8call.example.data.ConversationSummary( + it, getString(R.string.group_no_traffic), 0L, 0 + ) + } + val shown = mine + emptyGroups + adapter.submitList(shown) + conversationCallsigns = shown.map { it.callsign } + otherGroupsButton.visibility = if (other.isEmpty()) View.GONE else View.VISIBLE // Show/hide empty state - if (conversations.isEmpty()) { + if (shown.isEmpty()) { emptyState.visibility = View.VISIBLE recyclerView.visibility = View.GONE } else { @@ -79,6 +138,42 @@ class MessagesFragment : Fragment() { } } + private fun subscribedGroups(): List { + val prefs = androidx.preference.PreferenceManager + .getDefaultSharedPreferences(requireContext()) + return (prefs.getString("my_groups", "") ?: "") + .split(",").map { it.trim().uppercase() }.filter { it.isNotEmpty() } + } + + private fun showNewMessageDialog() { + val dialogView = layoutInflater.inflate(R.layout.dialog_new_message, null) + val input = dialogView.findViewById(R.id.callsign_input) + + // Suggest stations from conversation history, this session's + // decodes, and the well-known groups from the protocol table + val suggestions = ( + conversationCallsigns + + decodeViewModel.heardCallsigns() + + com.js8call.example.util.Js8Groups.SUGGESTED + ).distinct() + input.setAdapter( + ArrayAdapter(requireContext(), android.R.layout.simple_list_item_1, suggestions) + ) + input.threshold = 1 + + MaterialAlertDialogBuilder(requireContext()) + .setTitle(R.string.messages_new) + .setView(dialogView) + .setPositiveButton(android.R.string.ok) { _, _ -> + val callsign = input.text?.toString()?.trim()?.uppercase().orEmpty() + if (callsign.isNotEmpty()) { + navigateToConversation(callsign) + } + } + .setNegativeButton(android.R.string.cancel, null) + .show() + } + private fun navigateToConversation(callsign: String) { val bundle = Bundle().apply { putString("callsign", callsign) diff --git a/android/app/src/main/java/com/js8call/example/ui/MessagesViewModel.kt b/android/app/src/main/java/com/js8call/example/ui/MessagesViewModel.kt index c30bbf6d8..96fd4fec2 100644 --- a/android/app/src/main/java/com/js8call/example/ui/MessagesViewModel.kt +++ b/android/app/src/main/java/com/js8call/example/ui/MessagesViewModel.kt @@ -38,6 +38,20 @@ class MessagesViewModel(application: Application) : AndroidViewModel(application return repository.getMessagesForConversation(callsign) } + /** + * The relay path stored for a thread, in `A>B` notation, or null for a + * thread that transmits direct. + */ + fun getRelayPath(callsign: String): LiveData { + return repository.getRelayPath(callsign) + } + + fun setRelayPath(callsign: String, path: String?) { + viewModelScope.launch { + repository.setRelayPath(callsign, path) + } + } + /** * Mark all messages in a conversation as read. */ @@ -62,25 +76,37 @@ class MessagesViewModel(application: Application) : AndroidViewModel(application text: String, snr: Int? = null, frequency: Float? = null, - relayPath: String? = null + relayPath: String? = null, + markRead: Boolean = false ) { viewModelScope.launch { - repository.insertIncomingMessage(conversationId, from, text, snr, frequency, relayPath) + repository.insertIncomingMessage( + conversationId, from, text, snr, frequency, relayPath, markRead + ) } } /** * Insert an outgoing message (when user sends). */ - fun insertOutgoingMessage(to: String, text: String): LiveData { + fun insertOutgoingMessage(to: String, text: String, relayPath: String? = null): LiveData { val result = MutableLiveData() viewModelScope.launch { - val id = repository.insertOutgoingMessage(to, text, MessageEntity.STATUS_PENDING) + val id = repository.insertOutgoingMessage( + to, text, MessageEntity.STATUS_PENDING, relayPath + ) result.postValue(id) } return result } + /** An inbound ACK: receipt for the newest sent message in the thread. */ + fun markLatestSentAcked(conversationId: String) { + viewModelScope.launch { + repository.markLatestSentAcked(conversationId) + } + } + /** * Update message status (e.g., when TX completes or ACK received). */ diff --git a/android/app/src/main/java/com/js8call/example/ui/MonitorFragment.kt b/android/app/src/main/java/com/js8call/example/ui/MonitorFragment.kt index 52eac3826..81f331b4e 100644 --- a/android/app/src/main/java/com/js8call/example/ui/MonitorFragment.kt +++ b/android/app/src/main/java/com/js8call/example/ui/MonitorFragment.kt @@ -1,32 +1,34 @@ package com.js8call.example.ui import android.Manifest -import android.content.Context import android.content.Intent import android.content.pm.PackageManager -import android.media.AudioDeviceInfo -import android.media.AudioManager -import android.os.Build +import android.content.res.ColorStateList import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup -import android.widget.AdapterView -import android.widget.ArrayAdapter -import android.widget.Button -import android.widget.Spinner +import android.widget.ImageView +import android.widget.PopupMenu import android.widget.TextView +import androidx.appcompat.app.AlertDialog import androidx.core.content.ContextCompat import androidx.fragment.app.Fragment import androidx.lifecycle.ViewModelProvider +import com.google.android.material.button.MaterialButton +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.google.android.material.materialswitch.MaterialSwitch import com.google.android.material.snackbar.Snackbar +import com.google.android.material.textfield.TextInputEditText +import com.google.android.material.textfield.TextInputLayout import com.js8call.example.R import com.js8call.example.model.EngineState +import com.js8call.example.model.TransmitState import com.js8call.example.service.JS8EngineService /** * Fragment for monitoring/receiving screen. - * Shows waterfall display and engine status. + * Shows the waterfall and the status strip below it. */ class MonitorFragment : Fragment() { @@ -34,30 +36,34 @@ class MonitorFragment : Fragment() { private lateinit var transmitViewModel: TransmitViewModel private lateinit var waterfallView: WaterfallView + private lateinit var stateDot: ImageView private lateinit var statusText: TextView - private lateinit var snrValue: TextView - private lateinit var powerValue: TextView - private lateinit var txOffsetValue: TextView - private lateinit var timeDriftValue: TextView - private lateinit var timeSyncButton: Button - private lateinit var timeDriftResetButton: Button - private lateinit var audioDeviceSpinner: Spinner - private lateinit var frequencySpinner: Spinner - private lateinit var startStopButton: Button - private lateinit var monitorVersionText: TextView - - // Audio device management - private var audioDeviceAdapter: ArrayAdapter? = null - private var availableDevices = mutableListOf() - private var isUpdatingSpinner = false - private var userInitiatedAudioSelection = false - private var lastSelectedAudioDeviceId = -1 + private lateinit var rigIndicator: ImageView + private lateinit var frequencyButton: MaterialButton + private lateinit var powerSwitch: MaterialSwitch + private lateinit var telemetryText: TextView + private lateinit var overflowButton: MaterialButton + private lateinit var timingCard: View + private lateinit var timingCardText: TextView + private lateinit var timingDismiss: MaterialButton + private lateinit var timingApply: MaterialButton + + private var searchDeadlineMs: Long? = null + private var countdownTicker: Runnable? = null // Frequency management - // Spinner position last applied programmatically; onItemSelected skips it - // because setSelection() fires the listener asynchronously. + private var frequencyEntries = listOf() + private var frequencyValues = listOf() private var appliedFrequencyIndex = -1 + // Engine and transmit state both feed the dot and the state word. + private var engineState = EngineState.STOPPED + private var transmitState = TransmitState.IDLE + + // Set true while the switch is moved in code, so the listener can tell a + // state update apart from a tap. + private var applyingSwitchState = false + override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, @@ -75,25 +81,17 @@ class MonitorFragment : Fragment() { // Find views waterfallView = view.findViewById(R.id.waterfall_view) + stateDot = view.findViewById(R.id.state_dot) statusText = view.findViewById(R.id.status_text) - snrValue = view.findViewById(R.id.snr_value) - powerValue = view.findViewById(R.id.power_value) - txOffsetValue = view.findViewById(R.id.tx_offset_value) - timeDriftValue = view.findViewById(R.id.time_drift_value) - timeSyncButton = view.findViewById(R.id.time_sync_button) - timeDriftResetButton = view.findViewById(R.id.time_drift_reset_button) - monitorVersionText = view.findViewById(R.id.monitor_version) - audioDeviceSpinner = view.findViewById(R.id.audio_device_spinner) - frequencySpinner = view.findViewById(R.id.frequency_spinner) - startStopButton = view.findViewById(R.id.start_stop_button) - - // Set version text dynamically from package info - val versionName = try { - requireContext().packageManager.getPackageInfo(requireContext().packageName, 0).versionName - } catch (e: PackageManager.NameNotFoundException) { - "unknown" - } - monitorVersionText.text = "Version: $versionName" + rigIndicator = view.findViewById(R.id.rig_indicator) + frequencyButton = view.findViewById(R.id.frequency_button) + powerSwitch = view.findViewById(R.id.power_switch) + telemetryText = view.findViewById(R.id.telemetry_text) + overflowButton = view.findViewById(R.id.monitor_overflow) + timingCard = view.findViewById(R.id.timing_card) + timingCardText = view.findViewById(R.id.timing_card_text) + timingDismiss = view.findViewById(R.id.timing_dismiss) + timingApply = view.findViewById(R.id.timing_apply) // Set up waterfall offset callback waterfallView.bindRenderer(viewModel.getWaterfallRenderer()) @@ -110,82 +108,69 @@ class MonitorFragment : Fragment() { requireContext().startService(intent) } - // Set up audio device spinner - setupAudioDeviceSpinner() - - // Set up frequency spinner - setupFrequencySpinner() - - // Set up observers + loadFrequencies() observeViewModel() - // Set up click listeners - startStopButton.setOnClickListener { - toggleMonitoring() + powerSwitch.setOnCheckedChangeListener { _, isChecked -> + if (applyingSwitchState) return@setOnCheckedChangeListener + if (isChecked) startMonitoring() else stopMonitoring() } - timeSyncButton.setOnClickListener { - val intent = Intent(requireContext(), JS8EngineService::class.java).apply { - action = JS8EngineService.ACTION_TIME_SYNC_ONCE - } - requireContext().startService(intent) - Snackbar.make(requireView(), getString(R.string.monitor_time_sync_armed), Snackbar.LENGTH_SHORT).show() + frequencyButton.setOnClickListener { showFrequencyDialog() } + overflowButton.setOnClickListener { showOverflowMenu(it) } + // The strip already reads out the drift, so let it edit the drift too + telemetryText.setOnClickListener { showTimeDriftDialog() } + + timingDismiss.setOnClickListener { + // Clears the badge and any snackbar too; the card is not the only + // surface, so a local hide would leave the others up. + viewModel.updateTimingSuggestion(null) + } + timingApply.setOnClickListener { + val suggestion = viewModel.timingSuggestion.value ?: return@setOnClickListener + applyTimeDrift(suggestion.driftMs) + viewModel.updateTimingSuggestion(null) + Snackbar.make( + requireView(), + getString( + R.string.monitor_timing_applied, + String.format("%.1f s", suggestion.driftMs / 1000.0) + ), + Snackbar.LENGTH_LONG + ).show() } - timeDriftResetButton.setOnClickListener { - val intent = Intent(requireContext(), JS8EngineService::class.java).apply { - action = JS8EngineService.ACTION_SET_TIME_DRIFT - putExtra(JS8EngineService.EXTRA_TIME_DRIFT_MS, 0L) + // MainActivity raises the snackbar only when this screen is not up, so + // the card is the whole story here. + viewModel.timingSuggestion.observe(viewLifecycleOwner) { suggestion -> + if (suggestion?.kind == JS8EngineService.TIMING_SEARCHING) { + startTimingCountdown(suggestion) + } else { + stopTimingCountdown() } - requireContext().startService(intent) + renderTimingCard(suggestion) + renderTelemetry() } - - } - - override fun onPause() { - super.onPause() - userInitiatedAudioSelection = false } override fun onResume() { super.onResume() - refreshAudioDevices() + updateRigIndicator() } override fun onDestroyView() { + stopTimingCountdown() super.onDestroyView() } private fun observeViewModel() { // Observe status viewModel.status.observe(viewLifecycleOwner) { status -> - updateStatus(status.state) - - // Update SNR - snrValue.text = if (status.snr != 0) { - getString(R.string.format_snr, status.snr) - } else { - "--" - } - - // Update power - powerValue.text = if (status.powerDb != 0f) { - String.format("%.1f dB", status.powerDb) - } else { - "--" - } - - // Update TX offset - txOffsetValue.text = "${status.txOffsetHz.toInt()} Hz" + engineState = status.state + renderState() + renderTelemetry() waterfallView.txOffsetHz = status.txOffsetHz - // Update time drift - timeDriftValue.text = if (status.timeDriftMs != 0L) { - String.format("%+d ms", status.timeDriftMs) - } else { - "0 ms" - } - // Show error if present status.errorMessage?.let { error -> Snackbar.make(requireView(), error, Snackbar.LENGTH_LONG).show() @@ -193,11 +178,13 @@ class MonitorFragment : Fragment() { } } - // Observe running state - viewModel.isRunning.observe(viewLifecycleOwner) { isRunning -> - updateButtonState(isRunning) + transmitViewModel.txState.observe(viewLifecycleOwner) { state -> + transmitState = state ?: TransmitState.IDLE + renderState() } + viewModel.rigConnected.observe(viewLifecycleOwner) { updateRigIndicator() } + viewModel.radioFrequency.observe(viewLifecycleOwner) { frequencyHz -> if (frequencyHz != null && frequencyHz > 0) { updateFrequencyFromRadio(frequencyHz) @@ -205,34 +192,272 @@ class MonitorFragment : Fragment() { } } - private fun updateStatus(state: EngineState) { - statusText.text = when (state) { - EngineState.STOPPED -> getString(R.string.monitor_status_stopped) - EngineState.STARTING -> getString(R.string.monitor_status_starting) - EngineState.RUNNING -> getString(R.string.monitor_status_running) - EngineState.ERROR -> "ERROR" + /** + * Paint the state dot, the state word and the switch. + * + * The engine is the only switchable thing on this screen, so transmit shows + * up here as a state rather than as a control of its own. + */ + private fun renderState() { + val transmitting = engineState == EngineState.RUNNING && + transmitState == TransmitState.TRANSMITTING + + val labelRes = when { + transmitting -> R.string.monitor_state_transmitting + engineState == EngineState.RUNNING -> R.string.monitor_state_receiving + engineState == EngineState.STARTING -> R.string.monitor_status_starting + engineState == EngineState.ERROR -> R.string.monitor_state_error + else -> R.string.monitor_state_off } + val colorRes = when { + transmitting -> R.color.tx_button_transmitting + engineState == EngineState.RUNNING -> R.color.snr_excellent + engineState == EngineState.STARTING -> R.color.tx_button_queued + engineState == EngineState.ERROR -> R.color.message_failed + else -> R.color.message_pending + } + + statusText.setText(labelRes) + // Transmitting and Error are both red, so an error changes the mark + // itself rather than relying on a shade the eye has to measure. + stateDot.setImageResource( + if (engineState == EngineState.ERROR) R.drawable.ic_error_outline + else R.drawable.status_dot + ) + stateDot.imageTintList = + ColorStateList.valueOf(ContextCompat.getColor(requireContext(), colorRes)) + + val shouldBeOn = engineState == EngineState.RUNNING || engineState == EngineState.STARTING + if (powerSwitch.isChecked != shouldBeOn) { + applyingSwitchState = true + powerSwitch.isChecked = shouldBeOn + applyingSwitchState = false + } + + // A missing rig link only counts against a running engine + updateRigIndicator() } - private fun updateButtonState(isRunning: Boolean) { - if (isRunning) { - startStopButton.text = getString(R.string.monitor_stop) - startStopButton.setCompoundDrawablesWithIntrinsicBounds(android.R.drawable.ic_media_pause, 0, 0, 0) + private fun renderTelemetry() { + val status = viewModel.status.value ?: return + val offset = if (status.timeDriftMs != 0L) { + String.format("%+d ms", status.timeDriftMs) } else { - startStopButton.text = getString(R.string.monitor_start) - startStopButton.setCompoundDrawablesWithIntrinsicBounds(android.R.drawable.ic_media_play, 0, 0, 0) + "0 ms" + } + // The offset stays put while the search runs; the countdown is the + // trials still to come, each one frame long. + val drift = timingSearchSecondsLeft()?.let { + getString(R.string.monitor_timing_checking, offset, it) + } ?: offset + val offsetAndDrift = getString( + R.string.monitor_telemetry, + status.txOffsetHz.toInt(), + drift + ) + // A stopped engine reads no power, and a leading placeholder just adds noise + telemetryText.text = if (status.powerDb != 0f) { + getString(R.string.monitor_telemetry_power, status.powerDb, offsetAndDrift) + } else { + offsetAndDrift } } - private fun toggleMonitoring() { - if (viewModel.isRunning.value == true) { - stopMonitoring() - } else { - startMonitoring() + /** + * Show the rig link only when rig control is switched on in Settings. + * + * Grey means nothing has tried to connect yet, red means the engine is + * running without a link, and green means CAT is alive. These are the + * colors the state dot uses for the same three ideas. + */ + private fun updateRigIndicator() { + if (!isAdded) return + val prefs = androidx.preference.PreferenceManager.getDefaultSharedPreferences(requireContext()) + val rigEnabled = prefs.getBoolean("rig_control_enabled", false) && + prefs.getString("rig_type", "none") != "none" + if (!rigEnabled) { + rigIndicator.visibility = View.GONE + return + } + + rigIndicator.visibility = View.VISIBLE + val connected = viewModel.rigConnected.value == true + // A failed start is usually the rig failing to connect, so an error + // counts as an attempt: grey there would deny the very thing that broke. + val attempted = engineState == EngineState.RUNNING || engineState == EngineState.ERROR + val colorRes = when { + connected -> R.color.snr_excellent + engineState == EngineState.STARTING -> R.color.tx_button_queued + attempted -> R.color.message_failed + else -> R.color.message_pending + } + rigIndicator.imageTintList = + ColorStateList.valueOf(ContextCompat.getColor(requireContext(), colorRes)) + rigIndicator.contentDescription = getString( + when { + connected -> R.string.monitor_rig_connected + engineState == EngineState.STARTING -> R.string.monitor_rig_connecting + else -> R.string.monitor_rig_disconnected + } + ) + } + + private fun showOverflowMenu(anchor: View) { + val popup = PopupMenu(requireContext(), anchor) + popup.menuInflater.inflate(R.menu.monitor_overflow, popup.menu) + popup.setOnMenuItemClickListener { item -> + when (item.itemId) { + R.id.action_audio_device -> { + showAudioDeviceDialog() + true + } + R.id.action_time_sync -> { + armTimeSync() + true + } + R.id.action_time_drift_adjust -> { + showTimeDriftDialog() + true + } + R.id.action_time_drift_reset -> { + resetTimeDrift() + true + } + else -> false + } + } + popup.show() + } + + private fun armTimeSync() { + val intent = Intent(requireContext(), JS8EngineService::class.java).apply { + action = JS8EngineService.ACTION_TIME_SYNC_ONCE + } + requireContext().startService(intent) + Snackbar.make(requireView(), getString(R.string.monitor_time_sync_armed), Snackbar.LENGTH_SHORT).show() + } + + private fun resetTimeDrift() { + applyTimeDrift(0L) + } + + private fun applyTimeDrift(driftMs: Long) { + val intent = Intent(requireContext(), JS8EngineService::class.java).apply { + action = JS8EngineService.ACTION_SET_TIME_DRIFT + putExtra(JS8EngineService.EXTRA_TIME_DRIFT_MS, driftMs) + } + requireContext().startService(intent) + } + + /** Seconds until the sweep is spent, or null when no search is running. */ + private fun timingSearchSecondsLeft(): Int? { + val deadline = searchDeadlineMs ?: return null + val left = deadline - System.currentTimeMillis() + if (left <= 0L) return 0 + return ((left + 999L) / 1000L).toInt() + } + + /** + * Each remaining trial takes one frame, so the sweep ends that many frames + * out. Re-armed on every trial event, which keeps it honest if a cycle slips. + */ + private fun startTimingCountdown(suggestion: MonitorViewModel.TimingSuggestion) { + val remaining = (suggestion.steps - suggestion.step + 1).coerceAtLeast(0) + searchDeadlineMs = System.currentTimeMillis() + remaining.toLong() * suggestion.periodMs + countdownTicker?.let { telemetryText.removeCallbacks(it) } + val ticker = object : Runnable { + override fun run() { + if (searchDeadlineMs == null) return + renderTelemetry() + telemetryText.postDelayed(this, 1000L) + } + } + countdownTicker = ticker + telemetryText.post(ticker) + } + + private fun stopTimingCountdown() { + searchDeadlineMs = null + countdownTicker?.let { telemetryText.removeCallbacks(it) } + countdownTicker = null + } + + /** Only a find is actionable; a running search just tints the strip. */ + private fun renderTimingCard(suggestion: MonitorViewModel.TimingSuggestion?) { + val actionable = suggestion != null && + suggestion.kind == JS8EngineService.TIMING_FOUND + if (!actionable) { + timingCard.visibility = View.GONE + return + } + timingCardText.text = getString( + R.string.monitor_timing_found, + String.format("%.1f s", kotlin.math.abs(suggestion!!.driftMs) / 1000.0) + ) + timingCard.visibility = View.VISIBLE + } + + /** + * Manual drift entry, for when nothing decodes and the sync-from-decode + * path has nothing to work from. + */ + private fun showTimeDriftDialog() { + val view = layoutInflater.inflate(R.layout.dialog_time_drift, null) + val input = view.findViewById(R.id.drift_input) + val inputLayout = view.findViewById(R.id.drift_input_layout) + + input.setText((viewModel.status.value?.timeDriftMs ?: 0L).toString()) + input.setSelection(input.text?.length ?: 0) + + fun nudge(deltaMs: Long) { + val current = input.text?.toString()?.toLongOrNull() ?: 0L + input.setText((current + deltaMs).coerceIn(-DRIFT_LIMIT_MS, DRIFT_LIMIT_MS).toString()) + input.setSelection(input.text?.length ?: 0) + inputLayout.error = null + } + view.findViewById(R.id.nudge_minus_second).setOnClickListener { nudge(-1000L) } + view.findViewById(R.id.nudge_minus_fine).setOnClickListener { nudge(-100L) } + view.findViewById(R.id.nudge_plus_fine).setOnClickListener { nudge(100L) } + view.findViewById(R.id.nudge_plus_second).setOnClickListener { nudge(1000L) } + + val dialog = MaterialAlertDialogBuilder(requireContext()) + .setTitle(R.string.monitor_time_drift_title) + .setView(view) + .setPositiveButton(R.string.monitor_time_drift_set, null) + .setNegativeButton(android.R.string.cancel, null) + .setNeutralButton(R.string.monitor_time_drift_reset_action, null) + .create() + + dialog.setOnShowListener { + // Bound the button so a bad value keeps the dialog open + dialog.getButton(AlertDialog.BUTTON_POSITIVE).setOnClickListener { + val value = input.text?.toString()?.toLongOrNull() + if (value == null || value < -DRIFT_LIMIT_MS || value > DRIFT_LIMIT_MS) { + inputLayout.error = getString( + R.string.monitor_time_drift_range, -DRIFT_LIMIT_MS.toInt(), DRIFT_LIMIT_MS.toInt() + ) + return@setOnClickListener + } + applyTimeDrift(value) + Snackbar.make( + requireView(), + getString(R.string.monitor_time_drift_applied, value.toInt()), + Snackbar.LENGTH_SHORT + ).show() + dialog.dismiss() + } + dialog.getButton(AlertDialog.BUTTON_NEUTRAL).setOnClickListener { + input.setText("0") + inputLayout.error = null + } } + dialog.show() } private fun startMonitoring() { + // Starting an engine that is already up tears down its audio capture + if (engineState == EngineState.RUNNING || engineState == EngineState.STARTING) return + val prefs = androidx.preference.PreferenceManager.getDefaultSharedPreferences(requireContext()) val rigType = prefs.getString("rig_type", "none") val skipMicPermission = rigType == "trusdx_serial" @@ -246,26 +471,15 @@ class MonitorFragment : Fragment() { // Update view model viewModel.startMonitoring() - // Start service with selected audio device + // Start service with selected audio device. A saved choice that does not + // match what is plugged in resolves to an available input, and under a + // TruSDX that list only holds the rig's own inputs. val intent = Intent(requireContext(), JS8EngineService::class.java).apply { action = JS8EngineService.ACTION_START - // Pass selected device ID if any - if (availableDevices.isNotEmpty()) { - val selectedPos = audioDeviceSpinner.selectedItemPosition - if (selectedPos >= 0 && selectedPos < availableDevices.size) { - var selectedDevice = availableDevices[selectedPos] - if (rigType == "trusdx_serial" && - selectedDevice.id != JS8EngineService.TRUSDX_AUDIO_SERIAL_ID && - selectedDevice.id != JS8EngineService.TRUSDX_AUDIO_SPEAKER_ID - ) { - selectedDevice = availableDevices.firstOrNull { - it.id == JS8EngineService.TRUSDX_AUDIO_SERIAL_ID - } ?: selectedDevice - } - putExtra(JS8EngineService.EXTRA_AUDIO_DEVICE_ID, selectedDevice.id) - android.util.Log.d("MonitorFragment", - "Starting with device: ${selectedDevice.name} (ID: ${selectedDevice.id})") - } + AudioDevices.selected(requireContext())?.let { device -> + putExtra(JS8EngineService.EXTRA_AUDIO_DEVICE_ID, device.id) + android.util.Log.d("MonitorFragment", + "Starting with device: ${device.name} (ID: ${device.id})") } } ContextCompat.startForegroundService(requireContext(), intent) @@ -311,6 +525,8 @@ class MonitorFragment : Fragment() { // Permission granted, try starting again startMonitoring() } else { + // The switch moved on the tap that asked for the permission + renderState() Snackbar.make( requireView(), R.string.permission_audio_denied, @@ -320,143 +536,103 @@ class MonitorFragment : Fragment() { } } - private fun setupAudioDeviceSpinner() { - // Create adapter - audioDeviceAdapter = ArrayAdapter( - requireContext(), - android.R.layout.simple_spinner_item, - availableDevices - ) - audioDeviceAdapter?.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item) - audioDeviceSpinner.adapter = audioDeviceAdapter - - audioDeviceSpinner.setOnTouchListener { _, _ -> - userInitiatedAudioSelection = true - false - } - audioDeviceSpinner.setOnFocusChangeListener { _, hasFocus -> - if (!hasFocus) { - userInitiatedAudioSelection = false + private fun showAudioDeviceDialog() { + val running = viewModel.isRunning.value == true + AudioDevices.showPicker(requireContext(), running) { device -> + // A stopped engine has nothing to move, so say what will be used + val message = if (running) { + getString(R.string.monitor_audio_device_switching, device.name) + } else { + getString(R.string.monitor_audio_device_selected, device.name) } + Snackbar.make(requireView(), message, Snackbar.LENGTH_SHORT).show() } + } - // Set up selection listener - audioDeviceSpinner.onItemSelectedListener = object : AdapterView.OnItemSelectedListener { - override fun onItemSelected(parent: AdapterView<*>?, view: View?, position: Int, id: Long) { - val userInitiated = userInitiatedAudioSelection - userInitiatedAudioSelection = false - if (!userInitiated) return - if (isUpdatingSpinner) return - if (position < 0 || position >= availableDevices.size) return - - val selectedDevice = availableDevices[position] - android.util.Log.d("MonitorFragment", "Audio device selected: ${selectedDevice.name} (ID: ${selectedDevice.id})") - - // Only switch if engine is running - if (viewModel.isRunning.value == true) { - if (selectedDevice.id == lastSelectedAudioDeviceId) return - lastSelectedAudioDeviceId = selectedDevice.id - val prefs = androidx.preference.PreferenceManager.getDefaultSharedPreferences(requireContext()) - prefs.edit().putInt(PREF_LAST_AUDIO_DEVICE_ID, selectedDevice.id).apply() - switchAudioDevice(selectedDevice.id) - } - } + private fun loadFrequencies() { + val baseEntries = resources.getStringArray(R.array.js8_frequency_entries) + val baseValues = resources.getStringArray(R.array.js8_frequency_values) - override fun onNothingSelected(parent: AdapterView<*>?) { - // Do nothing - } - } + val prefs = androidx.preference.PreferenceManager.getDefaultSharedPreferences(requireContext()) + val customFrequencyMhz = prefs.getString("custom_frequency_mhz", "")?.trim().orEmpty() - // Populate with available devices - refreshAudioDevices() - } + val entries = baseEntries.toMutableList() + val values = baseValues.toMutableList() - private fun refreshAudioDevices() { - val prefs = androidx.preference.PreferenceManager.getDefaultSharedPreferences(requireContext()) - val rigType = prefs.getString("rig_type", "none") - if (rigType == "trusdx_serial") { - availableDevices.clear() - availableDevices.add(AudioDeviceItem(JS8EngineService.TRUSDX_AUDIO_SERIAL_ID, "TruSDX Serial")) - availableDevices.add(AudioDeviceItem(JS8EngineService.TRUSDX_AUDIO_SPEAKER_ID, "TruSDX Speaker")) - audioDeviceAdapter?.notifyDataSetChanged() - - val savedDeviceId = prefs.getInt(PREF_LAST_AUDIO_DEVICE_ID, JS8EngineService.TRUSDX_AUDIO_SERIAL_ID) - val selectedIndex = availableDevices.indexOfFirst { it.id == savedDeviceId } - .takeIf { it >= 0 } ?: 0 - isUpdatingSpinner = true - audioDeviceSpinner.setSelection(selectedIndex) - isUpdatingSpinner = false - lastSelectedAudioDeviceId = availableDevices[selectedIndex].id - return + val customFrequencyHz = customFrequencyMhz.toDoubleOrNull()?.let { mhz -> + if (mhz > 0) (mhz * 1_000_000.0).toLong() else null } - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) { - // Fallback for older versions - availableDevices.clear() - availableDevices.add(AudioDeviceItem(-1, "Default Microphone")) - audioDeviceAdapter?.notifyDataSetChanged() - return + if (customFrequencyHz != null) { + entries.add("Custom - ${customFrequencyMhz}MHz") + values.add(customFrequencyHz.toString()) } - val audioManager = requireContext().getSystemService(Context.AUDIO_SERVICE) as AudioManager - val devices = audioManager.getDevices(AudioManager.GET_DEVICES_INPUTS) + frequencyEntries = entries + frequencyValues = values - availableDevices.clear() + val defaultFrequency = baseValues.getOrNull(3) ?: "14078000" + val savedFrequency = prefs.getString("last_frequency", defaultFrequency) ?: defaultFrequency + val savedIndex = values.indexOf(savedFrequency).takeIf { it >= 0 } + ?: values.indexOf(defaultFrequency).takeIf { it >= 0 } + ?: 0 - for (device in devices) { - if (!device.isSource) continue - val deviceName = when (device.type) { - AudioDeviceInfo.TYPE_BUILTIN_MIC -> "Internal Microphone" - AudioDeviceInfo.TYPE_WIRED_HEADSET -> "Wired Headset" - AudioDeviceInfo.TYPE_USB_DEVICE -> { - device.productName?.toString() ?: "USB Audio Device" - } - AudioDeviceInfo.TYPE_USB_ACCESSORY -> "USB Audio Accessory" - AudioDeviceInfo.TYPE_USB_HEADSET -> "USB Headset" - AudioDeviceInfo.TYPE_BLUETOOTH_SCO -> "Bluetooth Headset" - AudioDeviceInfo.TYPE_BLUETOOTH_A2DP -> "Bluetooth Audio" - AudioDeviceInfo.TYPE_LINE_ANALOG -> "Line Input" - AudioDeviceInfo.TYPE_LINE_DIGITAL -> "Digital Line Input" - else -> continue // Skip unknown types + appliedFrequencyIndex = savedIndex + frequencyButton.text = shortFrequencyLabel(entries[savedIndex]) + } + + /** "20m - 14.078 MHz" is too wide for the strip, so show it as "20m · 14.078". */ + private fun shortFrequencyLabel(entry: String): String { + return entry.removeSuffix(" MHz").replace(" - ", " · ") + } + + private fun showFrequencyDialog() { + MaterialAlertDialogBuilder(requireContext()) + .setTitle(R.string.monitor_radio_frequency) + .setSingleChoiceItems( + frequencyEntries.toTypedArray(), + appliedFrequencyIndex + ) { dialog, which -> + dialog.dismiss() + selectFrequency(which) } + .show() + } - availableDevices.add(AudioDeviceItem(device.id, deviceName)) - android.util.Log.d("MonitorFragment", "Found audio device: $deviceName (ID: ${device.id})") - } + private fun selectFrequency(position: Int) { + if (position == appliedFrequencyIndex) return + if (position < 0 || position >= frequencyValues.size) return + appliedFrequencyIndex = position + frequencyButton.text = shortFrequencyLabel(frequencyEntries[position]) - // Add default option if no devices found - if (availableDevices.isEmpty()) { - availableDevices.add(AudioDeviceItem(-1, "Default Microphone")) - } + val frequencyHz = frequencyValues[position].toLongOrNull() ?: return + android.util.Log.d("MonitorFragment", "Frequency selected: ${frequencyEntries[position]} ($frequencyHz Hz)") - audioDeviceAdapter?.notifyDataSetChanged() + // Save frequency preference + val prefs = androidx.preference.PreferenceManager.getDefaultSharedPreferences(requireContext()) + prefs.edit().putString("last_frequency", frequencyValues[position]).apply() - if (availableDevices.isNotEmpty()) { - val savedDeviceId = prefs.getInt(PREF_LAST_AUDIO_DEVICE_ID, -1) - val selectedIndex = availableDevices.indexOfFirst { it.id == savedDeviceId } - .takeIf { it >= 0 } ?: 0 - isUpdatingSpinner = true - audioDeviceSpinner.setSelection(selectedIndex) - isUpdatingSpinner = false - lastSelectedAudioDeviceId = availableDevices[selectedIndex].id - } - } + // Check if rig control is enabled + val rigControlEnabled = prefs.getBoolean("rig_control_enabled", false) + val rigType = prefs.getString("rig_type", "none") - private fun switchAudioDevice(deviceId: Int) { - // Send intent to service to switch audio device - val intent = Intent(requireContext(), JS8EngineService::class.java).apply { - action = JS8EngineService.ACTION_SWITCH_AUDIO_DEVICE - putExtra(JS8EngineService.EXTRA_AUDIO_DEVICE_ID, deviceId) - } - requireContext().startService(intent) + if (rigControlEnabled && (rigType == "network" || rigType == "hamlib_usb" || rigType == "trusdx_serial")) { + // Send frequency change to service + val intent = Intent(requireContext(), JS8EngineService::class.java).apply { + action = JS8EngineService.ACTION_SET_FREQUENCY + putExtra(JS8EngineService.EXTRA_FREQUENCY_HZ, frequencyHz) + } + requireContext().startService(intent) - Snackbar.make(requireView(), "Switching audio device...", Snackbar.LENGTH_SHORT).show() + Snackbar.make(requireView(), "Setting frequency to ${frequencyEntries[position]}", Snackbar.LENGTH_SHORT).show() + } else if (rigControlEnabled && rigType == "rts_ptt") { + android.util.Log.d("MonitorFragment", "RTS PTT mode does not support frequency control") + } else { + android.util.Log.d("MonitorFragment", "Rig control not enabled or not supported type, skipping frequency change") + } } private fun updateFrequencyFromRadio(frequencyHz: Long) { - val frequencyValues = resources.getStringArray(R.array.js8_frequency_values) - val frequencyEntries = resources.getStringArray(R.array.js8_frequency_entries) - // Find the closest matching frequency in our list var closestIndex = 0 var closestDiff = Long.MAX_VALUE @@ -470,14 +646,13 @@ class MonitorFragment : Fragment() { } } - // Update spinner if we found a reasonable match (within 100 kHz) + // Update the label if we found a reasonable match (within 100 kHz) if (closestDiff < 100000) { - val currentIndex = frequencySpinner.selectedItemPosition - if (currentIndex == closestIndex) { + if (appliedFrequencyIndex == closestIndex) { return } appliedFrequencyIndex = closestIndex - frequencySpinner.setSelection(closestIndex) + frequencyButton.text = shortFrequencyLabel(frequencyEntries[closestIndex]) val prefs = androidx.preference.PreferenceManager.getDefaultSharedPreferences(requireContext()) prefs.edit().putString("last_frequency", frequencyValues[closestIndex]).apply() @@ -489,97 +664,11 @@ class MonitorFragment : Fragment() { } } - private fun setupFrequencySpinner() { - // Get frequency arrays from resources - val baseEntries = resources.getStringArray(R.array.js8_frequency_entries) - val baseValues = resources.getStringArray(R.array.js8_frequency_values) - - // Load saved frequency preference - val prefs = androidx.preference.PreferenceManager.getDefaultSharedPreferences(requireContext()) - val customFrequencyMhz = prefs.getString("custom_frequency_mhz", "")?.trim().orEmpty() - - val frequencyEntries = baseEntries.toMutableList() - val frequencyValues = baseValues.toMutableList() - - val customFrequencyHz = customFrequencyMhz.toDoubleOrNull()?.let { mhz -> - if (mhz > 0) (mhz * 1_000_000.0).toLong() else null - } - - if (customFrequencyHz != null) { - frequencyEntries.add("Custom - ${customFrequencyMhz}MHz") - frequencyValues.add(customFrequencyHz.toString()) - } - - // Create adapter - val adapter = ArrayAdapter( - requireContext(), - android.R.layout.simple_spinner_item, - frequencyEntries - ) - adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item) - frequencySpinner.adapter = adapter - - val defaultFrequency = baseValues.getOrNull(3) ?: "14078000" - val savedFrequency = prefs.getString("last_frequency", defaultFrequency) ?: defaultFrequency - val savedIndex = frequencyValues.indexOf(savedFrequency).takeIf { it >= 0 } - ?: frequencyValues.indexOf(defaultFrequency).takeIf { it >= 0 } - ?: 0 - - // Set initial selection - appliedFrequencyIndex = savedIndex - frequencySpinner.setSelection(savedIndex, false) - - // Set up selection listener - frequencySpinner.onItemSelectedListener = object : AdapterView.OnItemSelectedListener { - override fun onItemSelected(parent: AdapterView<*>?, view: View?, position: Int, id: Long) { - if (position == appliedFrequencyIndex) return - if (position < 0 || position >= frequencyValues.size) return - appliedFrequencyIndex = position - - val frequencyHz = frequencyValues[position].toLongOrNull() ?: return - android.util.Log.d("MonitorFragment", "Frequency selected: ${frequencyEntries[position]} ($frequencyHz Hz)") - - // Save frequency preference - prefs.edit().putString("last_frequency", frequencyValues[position]).apply() - - // Check if rig control is enabled - val rigControlEnabled = prefs.getBoolean("rig_control_enabled", false) - val rigType = prefs.getString("rig_type", "none") - - if (rigControlEnabled && (rigType == "network" || rigType == "hamlib_usb" || rigType == "trusdx_serial")) { - // Send frequency change to service - val intent = Intent(requireContext(), JS8EngineService::class.java).apply { - action = JS8EngineService.ACTION_SET_FREQUENCY - putExtra(JS8EngineService.EXTRA_FREQUENCY_HZ, frequencyHz) - } - requireContext().startService(intent) - - Snackbar.make(requireView(), "Setting frequency to ${frequencyEntries[position]}", Snackbar.LENGTH_SHORT).show() - } else if (rigControlEnabled && rigType == "rts_ptt") { - android.util.Log.d("MonitorFragment", "RTS PTT mode does not support frequency control") - } else { - android.util.Log.d("MonitorFragment", "Rig control not enabled or not supported type, skipping frequency change") - } - } - - override fun onNothingSelected(parent: AdapterView<*>?) { - // Do nothing - } - } - } - - /** - * Data class for audio device items in spinner. - */ - private data class AudioDeviceItem( - val id: Int, - val name: String - ) { - override fun toString(): String = name - } - companion object { private const val REQUEST_AUDIO_PERMISSION = 1 - private const val PREF_LAST_AUDIO_DEVICE_ID = "last_audio_device_id" + + // The ring aligns to the UTC minute, so anything past half a minute + // wraps onto a smaller offset and is never the value you want. + private const val DRIFT_LIMIT_MS = 30_000L } } diff --git a/android/app/src/main/java/com/js8call/example/ui/MonitorViewModel.kt b/android/app/src/main/java/com/js8call/example/ui/MonitorViewModel.kt index f8b124ce8..ce67c99b8 100644 --- a/android/app/src/main/java/com/js8call/example/ui/MonitorViewModel.kt +++ b/android/app/src/main/java/com/js8call/example/ui/MonitorViewModel.kt @@ -29,6 +29,21 @@ class MonitorViewModel(application: Application) : AndroidViewModel(application) private val _radioFrequency = MutableLiveData() val radioFrequency: LiveData = _radioFrequency + private val _rigConnected = MutableLiveData(false) + val rigConnected: LiveData = _rigConnected + + /** Null when the engine is not hunting for a clock offset. */ + data class TimingSuggestion( + val kind: Int, + val driftMs: Long, + val step: Int, + val steps: Int, + val periodMs: Int = 15_000 + ) + + private val _timingSuggestion = MutableLiveData(null) + val timingSuggestion: LiveData = _timingSuggestion + private val waterfallRenderer = WaterfallRenderer() init { @@ -64,6 +79,8 @@ class MonitorViewModel(application: Application) : AndroidViewModel(application) _isRunning.value = (state == EngineState.RUNNING) if (state == EngineState.STOPPED || state == EngineState.ERROR) { waterfallRenderer.clear() + // The rig link cannot outlive the engine that opened it + updateRigConnected(false) } } @@ -96,6 +113,14 @@ class MonitorViewModel(application: Application) : AndroidViewModel(application) _radioFrequency.value = frequency } + /** + * Update whether rig control has a live link to the radio. + */ + fun updateRigConnected(connected: Boolean) { + if (_rigConnected.value == connected) return + _rigConnected.value = connected + } + /** * Update audio device name. */ @@ -110,6 +135,10 @@ class MonitorViewModel(application: Application) : AndroidViewModel(application) _status.value = _status.value?.copy(timeDriftMs = driftMs) } + fun updateTimingSuggestion(suggestion: TimingSuggestion?) { + _timingSuggestion.value = suggestion + } + /** * Set the TX offset frequency in Hz. */ diff --git a/android/app/src/main/java/com/js8call/example/ui/NetworkMapFragment.kt b/android/app/src/main/java/com/js8call/example/ui/NetworkMapFragment.kt new file mode 100644 index 000000000..f9cf0bf56 --- /dev/null +++ b/android/app/src/main/java/com/js8call/example/ui/NetworkMapFragment.kt @@ -0,0 +1,86 @@ +package com.js8call.example.ui + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.TextView +import androidx.fragment.app.Fragment +import androidx.lifecycle.lifecycleScope +import androidx.navigation.fragment.findNavController +import androidx.preference.PreferenceManager +import com.google.android.material.appbar.MaterialToolbar +import com.google.android.material.chip.ChipGroup +import com.js8call.example.R +import com.js8call.example.data.LinkRepository +import com.js8call.example.util.NetworkGraph +import java.util.Locale +import kotlinx.coroutines.launch + +/** + * The who-hears-whom graph, aggregated from link observations. Tapping a + * station opens its contact card. The chips bound how old an observation + * may be, because on HF a link from last night is not a link right now. + */ +class NetworkMapFragment : Fragment() { + + private lateinit var mapView: NetworkMapView + private lateinit var emptyState: TextView + + private var windowMs = 24 * 60 * 60 * 1000L + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View? { + return inflater.inflate(R.layout.fragment_network_map, container, false) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + view.findViewById(R.id.map_toolbar) + .setNavigationOnClickListener { findNavController().navigateUp() } + + mapView = view.findViewById(R.id.network_map) + emptyState = view.findViewById(R.id.map_empty_state) + + mapView.onNodeClick = { callsign -> + if (!callsign.equals(myCallsign(), ignoreCase = true)) { + findNavController().navigate( + R.id.navigation_contact_detail, + Bundle().apply { putString("callsign", callsign) } + ) + } + } + + view.findViewById(R.id.age_chips).setOnCheckedStateChangeListener { _, ids -> + windowMs = when (ids.firstOrNull()) { + R.id.chip_1h -> 60 * 60 * 1000L + R.id.chip_7d -> 7 * 24 * 60 * 60 * 1000L + else -> 24 * 60 * 60 * 1000L + } + reload() + } + + // New observations land while the map is open; the row count is a + // cheap change signal that avoids re-querying on every decode. + LinkRepository.getInstance(requireContext()).countLive() + .observe(viewLifecycleOwner) { reload() } + } + + private fun myCallsign(): String = + PreferenceManager.getDefaultSharedPreferences(requireContext()) + .getString("callsign", "")?.trim()?.uppercase(Locale.US).orEmpty() + + private fun reload() { + val since = System.currentTimeMillis() - windowMs + viewLifecycleOwner.lifecycleScope.launch { + val observations = LinkRepository.getInstance(requireContext()).getSince(since) + val graph = NetworkGraph.build(observations, myCallsign()) + emptyState.visibility = if (graph.edges.isEmpty()) View.VISIBLE else View.GONE + mapView.setGraph(graph, myCallsign()) + } + } +} diff --git a/android/app/src/main/java/com/js8call/example/ui/NetworkMapView.kt b/android/app/src/main/java/com/js8call/example/ui/NetworkMapView.kt new file mode 100644 index 000000000..681eca605 --- /dev/null +++ b/android/app/src/main/java/com/js8call/example/ui/NetworkMapView.kt @@ -0,0 +1,383 @@ +package com.js8call.example.ui + +import android.content.Context +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.Paint +import android.graphics.Path +import android.util.AttributeSet +import android.util.TypedValue +import android.view.MotionEvent +import android.view.ScaleGestureDetector +import android.view.View +import androidx.core.content.ContextCompat +import com.js8call.example.R +import com.js8call.example.util.AvatarColor +import com.js8call.example.util.NetworkGraph +import kotlin.math.atan2 +import kotlin.math.cos +import kotlin.math.hypot +import kotlin.math.min +import kotlin.math.sin +import kotlin.math.sqrt + +/** + * The network map: stations as nodes, who-hears-whom as edges, laid out by + * a small force simulation with our own station pinned at the center. + * + * Same idiom as WaterfallView: a plain custom View drawing with Canvas, no + * dependencies. The graph is a JS8 band, a few dozen nodes at the outside, + * so simple O(n²) repulsion per frame is nowhere near a problem. + * + * Gestures: drag a node to rearrange, drag empty space to pan, pinch to + * zoom, tap a node to open it (via [onNodeClick]). + */ +class NetworkMapView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null +) : View(context, attrs) { + + var onNodeClick: ((String) -> Unit)? = null + + private class Node( + val callsign: String, + var x: Float, + var y: Float, + var vx: Float = 0f, + var vy: Float = 0f, + val pinned: Boolean = false + ) + + private var nodes = mutableListOf() + private var edges: List = emptyList() + private var myCallsign: String = "" + private var newestObservation = 0L + private var oldestObservation = 0L + + // World-to-screen transform + private var panX = 0f + private var panY = 0f + private var zoom = 1f + + private val density = resources.displayMetrics.density + private val nodeRadius = 22f * density + private val myNodeRadius = 26f * density + + // Simulation constants, in world units (which equal screen pixels at + // zoom 1). Rest length spaces first-ring nodes comfortably apart. + private val springLength = 170f * density + private val springK = 0.02f + private val repulsionK = 90000f * density * density + private val damping = 0.80f + private val maxVelocity = 40f * density + private var settled = false + + private val edgePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + style = Paint.Style.STROKE + strokeCap = Paint.Cap.ROUND + } + private val arrowPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { style = Paint.Style.FILL } + private val nodePaint = Paint(Paint.ANTI_ALIAS_FLAG) + private val ringPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + style = Paint.Style.STROKE + strokeWidth = 3f * density + } + private val initialPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = Color.WHITE + textAlign = Paint.Align.CENTER + textSize = 16f * density + isFakeBoldText = true + } + private val labelPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + textAlign = Paint.Align.CENTER + textSize = 11f * density + } + private val arrowPath = Path() + + private val edgeColor = themeColor(com.google.android.material.R.attr.colorOnSurfaceVariant) + private val labelColor = themeColor(com.google.android.material.R.attr.colorOnSurface) + private val ringColor = themeColor(androidx.appcompat.R.attr.colorPrimary) + + init { + labelPaint.color = labelColor + ringPaint.color = ringColor + } + + private fun themeColor(attr: Int): Int { + val tv = TypedValue() + context.theme.resolveAttribute(attr, tv, true) + return if (tv.resourceId != 0) ContextCompat.getColor(context, tv.resourceId) else tv.data + } + + /** Replace the graph, keeping positions of nodes that are still in it. */ + fun setGraph(graph: NetworkGraph.Graph, myCallsign: String) { + this.myCallsign = myCallsign + this.edges = graph.edges + newestObservation = graph.edges.maxOfOrNull { it.lastObservedAt } ?: 0L + oldestObservation = graph.edges.minOfOrNull { it.lastObservedAt } ?: 0L + + val existing = nodes.associateBy { it.callsign } + nodes = graph.nodes.map { call -> + existing[call] ?: seedNode(call) + }.toMutableList() + + settled = false + postInvalidateOnAnimation() + } + + /** + * A new node starts on a ring around the center at an angle hashed from + * its callsign, so the same stations land in roughly the same places + * every time the map opens. + */ + private fun seedNode(callsign: String): Node { + if (callsign == myCallsign) return Node(callsign, 0f, 0f, pinned = true) + val angle = (callsign.hashCode().toUInt().toDouble() % 6283.0) / 1000.0 + val ring = springLength * (1.0 + (callsign.hashCode().toUInt() % 40u).toDouble() / 100.0) + return Node( + callsign, + (cos(angle) * ring).toFloat(), + (sin(angle) * ring).toFloat() + ) + } + + private fun stepSimulation() { + if (settled || nodes.size < 2) return + + // Repulsion between every pair + for (i in nodes.indices) { + for (j in i + 1 until nodes.size) { + val a = nodes[i] + val b = nodes[j] + var dx = b.x - a.x + var dy = b.y - a.y + var d2 = dx * dx + dy * dy + if (d2 < 1f) { dx = 1f; dy = 1f; d2 = 2f } + val force = repulsionK / d2 + val d = sqrt(d2) + val fx = force * dx / d + val fy = force * dy / d + a.vx -= fx; a.vy -= fy + b.vx += fx; b.vy += fy + } + } + + // Springs along edges + val byCall = nodes.associateBy { it.callsign } + for (edge in edges) { + val a = byCall[edge.from] ?: continue + val b = byCall[edge.to] ?: continue + val dx = b.x - a.x + val dy = b.y - a.y + val d = hypot(dx, dy).coerceAtLeast(1f) + val force = springK * (d - springLength) + val fx = force * dx / d + val fy = force * dy / d + a.vx += fx; a.vy += fy + b.vx -= fx; b.vy -= fy + } + + // Weak gravity keeps disconnected pieces from drifting away + var kinetic = 0f + for (node in nodes) { + if (node.pinned || node === dragged) { + node.vx = 0f; node.vy = 0f + continue + } + node.vx = (node.vx - node.x * 0.001f) * damping + node.vy = (node.vy - node.y * 0.001f) * damping + val v = hypot(node.vx, node.vy) + if (v > maxVelocity) { + node.vx = node.vx / v * maxVelocity + node.vy = node.vy / v * maxVelocity + } + node.x += node.vx + node.y += node.vy + kinetic += node.vx * node.vx + node.vy * node.vy + } + + if (kinetic < 0.05f * density * density) settled = true + } + + override fun onDraw(canvas: Canvas) { + stepSimulation() + + canvas.save() + canvas.translate(width / 2f + panX, height / 2f + panY) + canvas.scale(zoom, zoom) + + val byCall = nodes.associateBy { it.callsign } + val drawnPairs = HashSet() + + for (edge in edges) { + val a = byCall[edge.from] ?: continue + val b = byCall[edge.to] ?: continue + + val strength = NetworkGraph.strength(edge.snr) + edgePaint.color = edgeColor + edgePaint.alpha = (80 + 160 * strength).toInt() + edgePaint.strokeWidth = (1.5f + 4.5f * strength) * density + + // One line per pair; a second direction adds only its arrowhead. + val key = pairKey(edge.from, edge.to) + if (drawnPairs.add(key)) { + canvas.drawLine(a.x, a.y, b.x, b.y, edgePaint) + } + + // Arrowhead near the listener: edge.from is the station that + // hears, so the arrow points into it. + drawArrowhead(canvas, fromX = b.x, fromY = b.y, toX = a.x, toY = a.y, + nodeRadius = radiusOf(edge.from), alpha = edgePaint.alpha) + } + + for (node in nodes) { + val r = radiusOf(node.callsign) + nodePaint.color = ContextCompat.getColor( + context, AvatarColor.forCallsign(node.callsign) + ) + canvas.drawCircle(node.x, node.y, r, nodePaint) + if (node.callsign == myCallsign) { + canvas.drawCircle(node.x, node.y, r + 4f * density, ringPaint) + } + canvas.drawText( + node.callsign.take(1), + node.x, + node.y - (initialPaint.ascent() + initialPaint.descent()) / 2f, + initialPaint + ) + // drawText's y is the baseline, so clear the circle (and the + // ring on our own node) by the text's ascent plus a real gap. + val ringExtra = if (node.callsign == myCallsign) 6f * density else 0f + canvas.drawText( + node.callsign, + node.x, + node.y + r + ringExtra + 10f * density - labelPaint.ascent(), + labelPaint + ) + } + + canvas.restore() + + if (!settled) postInvalidateOnAnimation() + } + + private fun radiusOf(callsign: String) = + if (callsign == myCallsign) myNodeRadius else nodeRadius + + private fun pairKey(a: String, b: String): Long { + val (lo, hi) = if (a < b) a to b else b to a + return lo.hashCode().toLong() shl 32 xor (hi.hashCode().toLong() and 0xFFFFFFFFL) + } + + private fun drawArrowhead( + canvas: Canvas, fromX: Float, fromY: Float, toX: Float, toY: Float, + nodeRadius: Float, alpha: Int + ) { + val angle = atan2((toY - fromY).toDouble(), (toX - fromX).toDouble()) + // Tip sits just outside the node circle + val tipX = toX - (nodeRadius + 6f * density) * cos(angle).toFloat() + val tipY = toY - (nodeRadius + 6f * density) * sin(angle).toFloat() + val size = 9f * density + val back = angle + Math.PI + val spread = 0.45 + arrowPath.reset() + arrowPath.moveTo(tipX, tipY) + arrowPath.lineTo( + tipX + (size * cos(back - spread)).toFloat(), + tipY + (size * sin(back - spread)).toFloat() + ) + arrowPath.lineTo( + tipX + (size * cos(back + spread)).toFloat(), + tipY + (size * sin(back + spread)).toFloat() + ) + arrowPath.close() + arrowPaint.color = edgeColor + arrowPaint.alpha = alpha + canvas.drawPath(arrowPath, arrowPaint) + } + + // ------------------------------------------------------------------ + // Gestures + + private var dragged: Node? = null + private var lastTouchX = 0f + private var lastTouchY = 0f + private var downX = 0f + private var downY = 0f + private var moved = false + + private val scaleDetector = ScaleGestureDetector( + context, + object : ScaleGestureDetector.SimpleOnScaleGestureListener() { + override fun onScale(detector: ScaleGestureDetector): Boolean { + zoom = (zoom * detector.scaleFactor).coerceIn(0.3f, 3f) + invalidate() + return true + } + } + ) + + override fun onTouchEvent(event: MotionEvent): Boolean { + scaleDetector.onTouchEvent(event) + + when (event.actionMasked) { + MotionEvent.ACTION_DOWN -> { + lastTouchX = event.x + lastTouchY = event.y + downX = event.x + downY = event.y + moved = false + dragged = hitTest(event.x, event.y) + } + MotionEvent.ACTION_MOVE -> { + if (scaleDetector.isInProgress) return true + val dx = event.x - lastTouchX + val dy = event.y - lastTouchY + if (hypot(event.x - downX, event.y - downY) > 8f * density) moved = true + val node = dragged + if (node != null) { + if (!node.pinned) { + node.x += dx / zoom + node.y += dy / zoom + settled = false + } + } else { + panX += dx + panY += dy + } + lastTouchX = event.x + lastTouchY = event.y + invalidate() + } + MotionEvent.ACTION_UP -> { + val node = dragged + dragged = null + if (node != null && !moved) { + onNodeClick?.invoke(node.callsign) + } else if (node != null) { + node.vx = 0f; node.vy = 0f + settled = false + postInvalidateOnAnimation() + } + } + MotionEvent.ACTION_CANCEL -> dragged = null + } + return true + } + + private fun hitTest(screenX: Float, screenY: Float): Node? { + val worldX = (screenX - width / 2f - panX) / zoom + val worldY = (screenY - height / 2f - panY) / zoom + return nodes.lastOrNull { + hypot(it.x - worldX, it.y - worldY) <= radiusOf(it.callsign) + 10f * density + } + } + + override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) { + super.onSizeChanged(w, h, oldw, oldh) + // Start zoomed to fit a first ring comfortably on small screens + if (oldw == 0 && w > 0) { + zoom = min(1f, w / (springLength * 3.2f)).coerceAtLeast(0.5f) + } + } +} diff --git a/android/app/src/main/java/com/js8call/example/ui/OtherGroupsFragment.kt b/android/app/src/main/java/com/js8call/example/ui/OtherGroupsFragment.kt new file mode 100644 index 000000000..92acd22c8 --- /dev/null +++ b/android/app/src/main/java/com/js8call/example/ui/OtherGroupsFragment.kt @@ -0,0 +1,108 @@ +package com.js8call.example.ui + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModelProvider +import androidx.navigation.fragment.findNavController +import androidx.preference.PreferenceManager +import androidx.recyclerview.widget.RecyclerView +import com.google.android.material.appbar.MaterialToolbar +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.google.android.material.snackbar.Snackbar +import com.js8call.example.R +import com.js8call.example.data.ConversationSummary + +/** + * Group threads heard on the band that the operator is not in. Stored + * silently so joining a group hands over the history the app already + * decoded. Joining promotes the thread to the main list and turns on + * notifications for it. + */ +class OtherGroupsFragment : Fragment() { + + private lateinit var viewModel: MessagesViewModel + private lateinit var adapter: ConversationListAdapter + private lateinit var recyclerView: RecyclerView + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View? { + return inflater.inflate(R.layout.fragment_other_groups, container, false) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + viewModel = ViewModelProvider(requireActivity())[MessagesViewModel::class.java] + + view.findViewById(R.id.other_groups_toolbar) + .setNavigationOnClickListener { findNavController().navigateUp() } + + recyclerView = view.findViewById(R.id.other_groups_recycler_view) + adapter = ConversationListAdapter().apply { + onItemClick = { conversation -> openThread(conversation.callsign) } + onItemLongClick = { conversation -> + confirmJoin(conversation.callsign) + true + } + } + recyclerView.adapter = adapter + + viewModel.conversations.observe(viewLifecycleOwner) { conversations -> + refresh(conversations) + } + } + + private fun refresh(conversations: List) { + val subscribed = subscribedGroups() + val other = conversations.filter { + it.callsign.startsWith("@") && it.callsign.uppercase() !in subscribed + } + adapter.submitList(other) + // Joining the last group leaves nothing to browse + if (other.isEmpty()) { + findNavController().navigateUp() + } + } + + private fun openThread(group: String) { + findNavController().navigate( + R.id.navigation_conversation, + Bundle().apply { putString("callsign", group) } + ) + } + + private fun confirmJoin(group: String) { + MaterialAlertDialogBuilder(requireContext()) + .setTitle(group) + .setMessage(getString(R.string.join_group_confirm, group)) + .setPositiveButton(R.string.join_group) { _, _ -> joinGroup(group) } + .setNegativeButton(android.R.string.cancel, null) + .show() + } + + private fun joinGroup(group: String) { + val prefs = PreferenceManager.getDefaultSharedPreferences(requireContext()) + val groups = (prefs.getString("my_groups", "") ?: "") + .split(",").map { it.trim().uppercase() }.filter { it.isNotEmpty() } + if (group.uppercase() !in groups) { + prefs.edit() + .putString("my_groups", (groups + group.uppercase()).joinToString(",")) + .apply() + } + Snackbar.make(requireView(), getString(R.string.group_joined, group), Snackbar.LENGTH_SHORT) + .show() + viewModel.conversations.value?.let { refresh(it) } + } + + private fun subscribedGroups(): List { + val prefs = PreferenceManager.getDefaultSharedPreferences(requireContext()) + return (prefs.getString("my_groups", "") ?: "") + .split(",").map { it.trim().uppercase() }.filter { it.isNotEmpty() } + } +} diff --git a/android/app/src/main/java/com/js8call/example/ui/RelayCandidateAdapter.kt b/android/app/src/main/java/com/js8call/example/ui/RelayCandidateAdapter.kt new file mode 100644 index 000000000..f3a37f6ce --- /dev/null +++ b/android/app/src/main/java/com/js8call/example/ui/RelayCandidateAdapter.kt @@ -0,0 +1,70 @@ +package com.js8call.example.ui + +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.ImageButton +import android.widget.TextView +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListAdapter +import androidx.recyclerview.widget.RecyclerView +import com.js8call.example.R +import com.js8call.example.data.ContactEntity + +/** + * Stations we have heard, offered as the next hop. Only the first hop is + * really a station we hear ourselves, so this is a shortcut rather than the + * only way in: further hops are typed. + */ +class RelayCandidateAdapter( + private val onAdd: (String) -> Unit +) : ListAdapter(DIFF) { + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): CandidateViewHolder { + val view = LayoutInflater.from(parent.context) + .inflate(R.layout.item_relay_candidate, parent, false) + return CandidateViewHolder(view, onAdd) + } + + override fun onBindViewHolder(holder: CandidateViewHolder, position: Int) { + holder.bind(getItem(position)) + } + + class CandidateViewHolder( + view: View, + private val onAdd: (String) -> Unit + ) : RecyclerView.ViewHolder(view) { + + private val callsignText: TextView = view.findViewById(R.id.callsign_text) + private val detailText: TextView = view.findViewById(R.id.detail_text) + private val addButton: ImageButton = view.findViewById(R.id.add_button) + + fun bind(contact: ContactEntity) { + callsignText.text = contact.callsign + val snr = contact.snr?.let { "$it dB · " }.orEmpty() + detailText.text = snr + formatAge(contact.lastHeard) + itemView.setOnClickListener { onAdd(contact.callsign) } + addButton.setOnClickListener { onAdd(contact.callsign) } + } + + private fun formatAge(timestamp: Long): String { + val seconds = (System.currentTimeMillis() - timestamp) / 1000 + return when { + seconds < 60 -> itemView.resources.getString(R.string.contact_age_now) + seconds < 3600 -> "${seconds / 60}m" + seconds < 86400 -> "${seconds / 3600}h" + else -> "${seconds / 86400}d" + } + } + } + + companion object { + private val DIFF = object : DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: ContactEntity, newItem: ContactEntity) = + oldItem.callsign == newItem.callsign + + override fun areContentsTheSame(oldItem: ContactEntity, newItem: ContactEntity) = + oldItem == newItem + } + } +} diff --git a/android/app/src/main/java/com/js8call/example/ui/RelayHopAdapter.kt b/android/app/src/main/java/com/js8call/example/ui/RelayHopAdapter.kt new file mode 100644 index 000000000..c0b41b230 --- /dev/null +++ b/android/app/src/main/java/com/js8call/example/ui/RelayHopAdapter.kt @@ -0,0 +1,137 @@ +package com.js8call.example.ui + +import android.annotation.SuppressLint +import android.view.LayoutInflater +import android.view.MotionEvent +import android.view.View +import android.view.ViewGroup +import android.widget.ImageButton +import android.widget.ImageView +import android.widget.TextView +import androidx.recyclerview.widget.ItemTouchHelper +import androidx.recyclerview.widget.RecyclerView +import com.js8call.example.R + +/** + * The hops of a relay path, in transmit order, followed by a fixed row for + * the destination. The destination is shown because a path reads wrong + * without its end, but it is not a hop and cannot be dragged or removed. + */ +class RelayHopAdapter( + private val destination: String, + private val onRemove: (Int) -> Unit, + private val onStartDrag: (RecyclerView.ViewHolder) -> Unit +) : RecyclerView.Adapter() { + + private val hops = mutableListOf() + + val currentHops: List + get() = hops.toList() + + @SuppressLint("NotifyDataSetChanged") + fun setHops(newHops: List) { + hops.clear() + hops.addAll(newHops) + notifyDataSetChanged() + } + + fun move(from: Int, to: Int): Boolean { + if (from !in hops.indices || to !in hops.indices) return false + hops.add(to, hops.removeAt(from)) + notifyItemMoved(from, to) + // The position numbers of everything between the two rows changed + notifyItemRangeChanged(minOf(from, to), kotlin.math.abs(from - to) + 1) + return true + } + + override fun getItemCount(): Int = hops.size + 1 + + private fun isDestinationRow(position: Int): Boolean = position == hops.size + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): HopViewHolder { + val view = LayoutInflater.from(parent.context) + .inflate(R.layout.item_relay_hop, parent, false) + return HopViewHolder(view) + } + + @SuppressLint("ClickableViewAccessibility") + override fun onBindViewHolder(holder: HopViewHolder, position: Int) { + holder.position.text = holder.itemView.context.getString( + R.string.relay_position, position + 1 + ) + if (isDestinationRow(position)) { + holder.callsign.text = destination + holder.role.setText(R.string.relay_path_destination) + holder.role.visibility = View.VISIBLE + holder.remove.visibility = View.INVISIBLE + holder.handle.visibility = View.INVISIBLE + holder.handle.setOnTouchListener(null) + holder.remove.setOnClickListener(null) + } else { + holder.callsign.text = hops[position] + holder.role.visibility = View.GONE + holder.remove.visibility = View.VISIBLE + holder.handle.visibility = View.VISIBLE + holder.remove.setOnClickListener { + val index = holder.bindingAdapterPosition + if (index != RecyclerView.NO_POSITION) onRemove(index) + } + holder.handle.setOnTouchListener { _, event -> + if (event.actionMasked == MotionEvent.ACTION_DOWN) { + onStartDrag(holder) + } + false + } + } + } + + fun isDraggable(holder: RecyclerView.ViewHolder): Boolean { + val position = holder.bindingAdapterPosition + return position != RecyclerView.NO_POSITION && !isDestinationRow(position) + } + + class HopViewHolder(view: View) : RecyclerView.ViewHolder(view) { + val handle: ImageView = view.findViewById(R.id.drag_handle) + val position: TextView = view.findViewById(R.id.position_text) + val callsign: TextView = view.findViewById(R.id.callsign_text) + val role: TextView = view.findViewById(R.id.role_text) + val remove: ImageButton = view.findViewById(R.id.remove_button) + } +} + +/** + * Drag-to-reorder for the hop list. The destination row sits at the end and + * refuses both to move and to be moved through. + */ +class RelayHopTouchCallback( + private val adapter: RelayHopAdapter +) : ItemTouchHelper.Callback() { + + override fun isLongPressDragEnabled(): Boolean = false + + override fun isItemViewSwipeEnabled(): Boolean = false + + override fun getMovementFlags( + recyclerView: RecyclerView, + viewHolder: RecyclerView.ViewHolder + ): Int { + if (!adapter.isDraggable(viewHolder)) return makeMovementFlags(0, 0) + return makeMovementFlags(ItemTouchHelper.UP or ItemTouchHelper.DOWN, 0) + } + + override fun canDropOver( + recyclerView: RecyclerView, + current: RecyclerView.ViewHolder, + target: RecyclerView.ViewHolder + ): Boolean = adapter.isDraggable(target) + + override fun onMove( + recyclerView: RecyclerView, + viewHolder: RecyclerView.ViewHolder, + target: RecyclerView.ViewHolder + ): Boolean { + return adapter.move(viewHolder.bindingAdapterPosition, target.bindingAdapterPosition) + } + + override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) = Unit +} diff --git a/android/app/src/main/java/com/js8call/example/ui/RelayPathFragment.kt b/android/app/src/main/java/com/js8call/example/ui/RelayPathFragment.kt new file mode 100644 index 000000000..4414dc581 --- /dev/null +++ b/android/app/src/main/java/com/js8call/example/ui/RelayPathFragment.kt @@ -0,0 +1,218 @@ +package com.js8call.example.ui + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.view.inputmethod.EditorInfo +import android.widget.TextView +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModelProvider +import androidx.navigation.fragment.findNavController +import androidx.recyclerview.widget.ItemTouchHelper +import androidx.recyclerview.widget.RecyclerView +import com.google.android.material.appbar.MaterialToolbar +import com.google.android.material.snackbar.Snackbar +import com.google.android.material.textfield.TextInputEditText +import com.google.android.material.textfield.TextInputLayout +import com.js8call.example.R +import com.js8call.example.data.ContactEntity +import com.js8call.example.util.CallsignValidator +import com.js8call.example.util.RelayPath + +/** + * Sets the relay path for one thread: the stations that carry a message to + * its destination, in the order they carry it. + * + * This is live forwarding, so every station on the path has to be on the air + * when the message goes out. Leaving a message at a station for later pickup + * is the mailbox instead, on the thread menu. + */ +class RelayPathFragment : Fragment() { + + private lateinit var viewModel: MessagesViewModel + private lateinit var contactsViewModel: ContactsViewModel + private lateinit var hopAdapter: RelayHopAdapter + private lateinit var candidateAdapter: RelayCandidateAdapter + private lateinit var touchHelper: ItemTouchHelper + + private lateinit var hintText: TextView + private lateinit var addInput: TextInputEditText + private lateinit var addInputLayout: TextInputLayout + private lateinit var heardEmpty: TextView + private lateinit var heardRecycler: RecyclerView + + private var callsign: String = "" + private var loaded = false + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + callsign = arguments?.getString("callsign")?.trim()?.uppercase() ?: "" + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View? { + return inflater.inflate(R.layout.fragment_relay_path, container, false) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + viewModel = ViewModelProvider(requireActivity())[MessagesViewModel::class.java] + contactsViewModel = ViewModelProvider(requireActivity())[ContactsViewModel::class.java] + + val toolbar = view.findViewById(R.id.relay_path_toolbar) + toolbar.subtitle = getString(R.string.relay_path_subtitle, callsign) + toolbar.setNavigationOnClickListener { findNavController().navigateUp() } + toolbar.inflateMenu(R.menu.relay_path_menu) + toolbar.setOnMenuItemClickListener { item -> + when (item.itemId) { + R.id.action_save_relay_path -> { + save() + true + } + else -> false + } + } + + hintText = view.findViewById(R.id.hint_text) + addInput = view.findViewById(R.id.add_input) + addInputLayout = view.findViewById(R.id.add_input_layout) + heardEmpty = view.findViewById(R.id.heard_empty) + heardRecycler = view.findViewById(R.id.heard_recycler_view) + + hopAdapter = RelayHopAdapter( + destination = callsign, + onRemove = { index -> removeHop(index) }, + onStartDrag = { holder -> touchHelper.startDrag(holder) } + ) + val hopsRecycler = view.findViewById(R.id.hops_recycler_view) + hopsRecycler.adapter = hopAdapter + touchHelper = ItemTouchHelper(RelayHopTouchCallback(hopAdapter)) + touchHelper.attachToRecyclerView(hopsRecycler) + + candidateAdapter = RelayCandidateAdapter { call -> addHop(call) } + heardRecycler.adapter = candidateAdapter + + addInputLayout.setEndIconOnClickListener { addTypedHop() } + addInput.setOnEditorActionListener { _, actionId, _ -> + if (actionId == EditorInfo.IME_ACTION_DONE) { + addTypedHop() + true + } else { + false + } + } + + // The stored path seeds the list once. Later emissions are our own + // writes coming back, and re-seeding on those would undo edits the + // operator made after saving. + viewModel.getRelayPath(callsign).observe(viewLifecycleOwner) { stored -> + if (!loaded) { + loaded = true + hopAdapter.setHops(RelayPath.parse(stored)) + refreshHint() + } + } + + contactsViewModel.contacts.observe(viewLifecycleOwner) { contacts -> + showCandidates(contacts) + } + + refreshHint() + } + + private fun showCandidates(contacts: List) { + val hops = hopAdapter.currentHops.map { it.uppercase() }.toSet() + val available = contacts.filter { + val call = it.callsign.uppercase() + !call.startsWith("@") && call != callsign && call !in hops + } + candidateAdapter.submitList(available) + heardEmpty.visibility = if (available.isEmpty()) View.VISIBLE else View.GONE + heardRecycler.visibility = if (available.isEmpty()) View.GONE else View.VISIBLE + } + + private fun addTypedHop() { + val typed = addInput.text?.toString()?.trim()?.uppercase().orEmpty() + if (typed.isEmpty()) return + if (addHop(typed)) { + addInput.text?.clear() + } + } + + /** + * Only the first hop is a station we hear ourselves. Anything past it is + * somebody the previous hop hears and we do not, so a typed callsign is + * accepted as long as it is shaped like one. + */ + private fun addHop(call: String): Boolean { + val hop = call.trim().uppercase() + val hops = hopAdapter.currentHops + when { + hops.size >= RelayPath.MAX_HOPS -> { + toast(getString(R.string.relay_path_full, RelayPath.MAX_HOPS)) + return false + } + hop == callsign -> { + toast(getString(R.string.relay_path_not_destination, hop)) + return false + } + hops.any { it.equals(hop, ignoreCase = true) } -> { + toast(getString(R.string.relay_path_duplicate, hop)) + return false + } + !CallsignValidator.isAmateurCallsign(hop) -> { + toast(getString(R.string.relay_path_invalid_callsign, hop)) + return false + } + } + hopAdapter.setHops(hops + hop) + refreshHint() + contactsViewModel.contacts.value?.let { showCandidates(it) } + return true + } + + private fun removeHop(index: Int) { + val hops = hopAdapter.currentHops.toMutableList() + if (index !in hops.indices) return + hops.removeAt(index) + hopAdapter.setHops(hops) + refreshHint() + contactsViewModel.contacts.value?.let { showCandidates(it) } + } + + private fun refreshHint() { + hintText.text = if (hopAdapter.currentHops.isEmpty()) { + getString(R.string.relay_path_hint_direct, callsign) + } else { + getString(R.string.relay_path_hint) + } + } + + private fun save() { + val path = RelayPath.format(hopAdapter.currentHops) + viewModel.setRelayPath(callsign, path) + toastOnParent( + if (path == null) { + getString(R.string.relay_path_cleared) + } else { + getString(R.string.relay_path_saved) + } + ) + findNavController().navigateUp() + } + + private fun toast(message: String) { + Snackbar.make(requireView(), message, Snackbar.LENGTH_SHORT).show() + } + + /** Shown after navigating up, so it has to hang off the activity's view. */ + private fun toastOnParent(message: String) { + val root = requireActivity().findViewById(android.R.id.content) ?: return + Snackbar.make(root, message, Snackbar.LENGTH_SHORT).show() + } +} diff --git a/android/app/src/main/java/com/js8call/example/ui/SettingsFragment.kt b/android/app/src/main/java/com/js8call/example/ui/SettingsFragment.kt index 20faa26da..a7bc10766 100644 --- a/android/app/src/main/java/com/js8call/example/ui/SettingsFragment.kt +++ b/android/app/src/main/java/com/js8call/example/ui/SettingsFragment.kt @@ -12,8 +12,11 @@ import android.os.Looper import android.text.InputType import androidx.activity.result.contract.ActivityResultContracts import androidx.core.content.ContextCompat +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.lifecycleScope import androidx.preference.EditTextPreference import androidx.preference.ListPreference +import androidx.preference.Preference import androidx.preference.PreferenceFragmentCompat import androidx.preference.SwitchPreferenceCompat import com.google.android.material.snackbar.Snackbar @@ -22,6 +25,7 @@ import com.js8call.core.HamlibRigCatalog import com.js8call.core.UsbSerialPortCatalog import com.js8call.example.R import java.util.Locale +import kotlinx.coroutines.launch /** * Fragment for app settings. @@ -32,6 +36,7 @@ class SettingsFragment : PreferenceFragmentCompat() { private var pendingLocationListener: LocationListener? = null private var pendingLocationTimeout: Runnable? = null private var gridPreference: GridSquarePreference? = null + private var audioDevicePreference: Preference? = null private var pendingStoragePermissionEnable = false private var logPreference: SwitchPreferenceCompat? = null @@ -66,6 +71,12 @@ class SettingsFragment : PreferenceFragmentCompat() { override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { setPreferencesFromResource(R.xml.preferences, rootKey) + findPreference("app_version")?.summary = try { + requireContext().packageManager.getPackageInfo(requireContext().packageName, 0).versionName + } catch (e: PackageManager.NameNotFoundException) { + "unknown" + } + val prefs = preferenceManager.sharedPreferences if (prefs != null && !prefs.contains("my_status")) { val statusPref = findPreference("my_status") @@ -199,9 +210,24 @@ class SettingsFragment : PreferenceFragmentCompat() { } } + audioDevicePreference = findPreference("audio_device") + audioDevicePreference?.setOnPreferenceClickListener { + // Both screens share one picker, so a change made here also moves a + // live capture and shows up on the Monitor strip. + val engineRunning = ViewModelProvider(requireActivity())[MonitorViewModel::class.java] + .isRunning.value == true + AudioDevices.showPicker(requireContext(), engineRunning) { updateAudioDeviceSummary() } + true + } + gridPreference = findPreference("grid") gridPreference?.onUpdateClickListener = { onGridUpdateRequested() } + findPreference("clear_link_data")?.setOnPreferenceClickListener { + confirmClearLinkData() + true + } + logPreference = findPreference("log_messages_to_file") logPreference?.setOnPreferenceChangeListener { _, newValue -> val enable = newValue as? Boolean ?: false @@ -220,11 +246,39 @@ class SettingsFragment : PreferenceFragmentCompat() { } } + override fun onResume() { + super.onResume() + // Inputs come and go with what is plugged in, so re-read them on the + // way in rather than only when the screen is built. + updateAudioDeviceSummary() + } + override fun onStop() { cancelLocationRequest() super.onStop() } + private fun confirmClearLinkData() { + com.google.android.material.dialog.MaterialAlertDialogBuilder(requireContext()) + .setTitle(R.string.settings_clear_link_data) + .setMessage(R.string.settings_clear_link_data_confirm) + .setPositiveButton(android.R.string.ok) { _, _ -> + viewLifecycleOwner.lifecycleScope.launch { + com.js8call.example.data.LinkRepository.getInstance(requireContext()).clear() + view?.let { + Snackbar.make(it, R.string.settings_clear_link_data_done, Snackbar.LENGTH_SHORT).show() + } + } + } + .setNegativeButton(android.R.string.cancel, null) + .show() + } + + private fun updateAudioDeviceSummary() { + audioDevicePreference?.summary = AudioDevices.selectedName(requireContext()) + ?: getString(R.string.settings_audio_device_none) + } + private fun onGridUpdateRequested() { val context = context ?: return val granted = ContextCompat.checkSelfPermission( @@ -345,7 +399,8 @@ class SettingsFragment : PreferenceFragmentCompat() { } private fun applyGridFromLocation(location: Location) { - val grid = maidenheadFromLocation(location.latitude, location.longitude) + val grid = com.js8call.example.util.Maidenhead + .fromLatLon(location.latitude, location.longitude) gridPreference?.setGridValue(grid) } @@ -354,50 +409,6 @@ class SettingsFragment : PreferenceFragmentCompat() { Snackbar.make(view, messageResId, Snackbar.LENGTH_LONG).show() } - private fun maidenheadFromLocation(latitude: Double, longitude: Double): String { - var lon = -longitude - var lat = latitude.coerceIn(-90.0, 90.0) - if (lon < -180.0) lon += 360.0 - if (lon > 180.0) lon -= 360.0 - if (lon == 180.0) lon = 179.999999 - if (lat == 90.0) lat = 89.999999 - - val lonMinutes = (180.0 - lon) * 60.0 - val latMinutes = (lat + 90.0) * 60.0 - - val lonField = (lonMinutes / 1200.0).toInt().coerceIn(0, 17) - val latField = (latMinutes / 600.0).toInt().coerceIn(0, 17) - - val lonFieldRemainder = lonMinutes - lonField * 1200.0 - val latFieldRemainder = latMinutes - latField * 600.0 - - val lonSquare = (lonFieldRemainder / 120.0).toInt().coerceIn(0, 9) - val latSquare = (latFieldRemainder / 60.0).toInt().coerceIn(0, 9) - - val lonSquareRemainder = lonFieldRemainder - lonSquare * 120.0 - val latSquareRemainder = latFieldRemainder - latSquare * 60.0 - - val lonSub = (lonSquareRemainder / 5.0).toInt().coerceIn(0, 23) - val latSub = (latSquareRemainder / 2.5).toInt().coerceIn(0, 23) - - val lonSubRemainder = lonSquareRemainder - lonSub * 5.0 - val latSubRemainder = latSquareRemainder - latSub * 2.5 - - val lonExt = (lonSubRemainder / 0.5).toInt().coerceIn(0, 9) - val latExt = (latSubRemainder / 0.25).toInt().coerceIn(0, 9) - - return buildString(8) { - append(('A'.code + lonField).toChar()) - append(('A'.code + latField).toChar()) - append(('0'.code + lonSquare).toChar()) - append(('0'.code + latSquare).toChar()) - append(('A'.code + lonSub).toChar()) - append(('A'.code + latSub).toChar()) - append(('0'.code + lonExt).toChar()) - append(('0'.code + latExt).toChar()) - }.uppercase(Locale.US) - } - private fun normalizeSerialSelection( selection: String, prefs: android.content.SharedPreferences?, diff --git a/android/app/src/main/java/com/js8call/example/ui/SpeedChip.kt b/android/app/src/main/java/com/js8call/example/ui/SpeedChip.kt new file mode 100644 index 000000000..8b00a36bf --- /dev/null +++ b/android/app/src/main/java/com/js8call/example/ui/SpeedChip.kt @@ -0,0 +1,63 @@ +package com.js8call.example.ui + +import android.widget.PopupMenu +import androidx.preference.PreferenceManager +import com.google.android.material.button.MaterialButton +import com.js8call.example.R + +/** + * Toolbar chip showing the current TX speed; tapping opens the picker. + * The choice is saved to the same preference the engine reads. + */ +object SpeedChip { + + fun bind(chip: MaterialButton) { + chip.text = currentLabel(chip) + chip.setOnClickListener { showPicker(chip) } + } + + private fun showPicker(chip: MaterialButton) { + val popup = PopupMenu(chip.context, chip) + popup.menuInflater.inflate(R.menu.compose_speed_menu, popup.menu) + + val checkedItem = when (currentSubmode(chip)) { + TransmitViewModel.SUBMODE_SLOW -> R.id.speed_slow + TransmitViewModel.SUBMODE_FAST -> R.id.speed_fast + TransmitViewModel.SUBMODE_TURBO -> R.id.speed_turbo + else -> R.id.speed_normal + } + popup.menu.findItem(checkedItem)?.isChecked = true + + popup.setOnMenuItemClickListener { item -> + val submode = when (item.itemId) { + R.id.speed_slow -> TransmitViewModel.SUBMODE_SLOW + R.id.speed_normal -> TransmitViewModel.SUBMODE_NORMAL + R.id.speed_fast -> TransmitViewModel.SUBMODE_FAST + R.id.speed_turbo -> TransmitViewModel.SUBMODE_TURBO + else -> return@setOnMenuItemClickListener false + } + PreferenceManager.getDefaultSharedPreferences(chip.context) + .edit() + .putInt(TransmitViewModel.PREF_TX_SUBMODE, submode) + .apply() + chip.text = currentLabel(chip) + true + } + popup.show() + } + + private fun currentSubmode(chip: MaterialButton): Int { + return PreferenceManager.getDefaultSharedPreferences(chip.context) + .getInt(TransmitViewModel.PREF_TX_SUBMODE, TransmitViewModel.SUBMODE_NORMAL) + } + + private fun currentLabel(chip: MaterialButton): String { + val res = when (currentSubmode(chip)) { + TransmitViewModel.SUBMODE_SLOW -> R.string.tx_speed_slow + TransmitViewModel.SUBMODE_FAST -> R.string.tx_speed_fast + TransmitViewModel.SUBMODE_TURBO -> R.string.tx_speed_turbo + else -> R.string.tx_speed_normal + } + return chip.context.getString(res) + } +} diff --git a/android/app/src/main/java/com/js8call/example/ui/TransmitFragment.kt b/android/app/src/main/java/com/js8call/example/ui/TransmitFragment.kt deleted file mode 100644 index 23b891e98..000000000 --- a/android/app/src/main/java/com/js8call/example/ui/TransmitFragment.kt +++ /dev/null @@ -1,446 +0,0 @@ -package com.js8call.example.ui - -import android.os.Bundle -import android.text.Editable -import android.text.TextWatcher -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.widget.Button -import android.widget.TextView -import android.content.BroadcastReceiver -import android.content.Context -import android.content.Intent -import android.content.IntentFilter -import android.content.SharedPreferences -import android.content.res.ColorStateList -import android.widget.ArrayAdapter -import android.widget.AutoCompleteTextView -import android.widget.Filter -import androidx.core.content.ContextCompat -import androidx.fragment.app.Fragment -import androidx.lifecycle.ViewModelProvider -import androidx.localbroadcastmanager.content.LocalBroadcastManager -import androidx.preference.PreferenceManager -import androidx.recyclerview.widget.RecyclerView -import com.google.android.material.snackbar.Snackbar -import com.google.android.material.textfield.MaterialAutoCompleteTextView -import com.google.android.material.textfield.TextInputEditText -import com.js8call.example.R -import com.js8call.example.data.MessageRepository -import com.js8call.example.model.TransmitState -import com.js8call.example.service.JS8EngineService - -/** - * Fragment for composing and transmitting messages. - */ -class TransmitFragment : Fragment() { - - private lateinit var viewModel: TransmitViewModel - private lateinit var decodeViewModel: DecodeViewModel - - private lateinit var messageEditText: TextInputEditText - private lateinit var directedEditText: MaterialAutoCompleteTextView - private lateinit var sendButton: Button - private lateinit var queueRecyclerView: RecyclerView - private lateinit var queueEmptyText: TextView - private lateinit var txStatusText: TextView - private lateinit var modeSelect: AutoCompleteTextView - private lateinit var speedSelect: AutoCompleteTextView - private var selectedMode: TxMode = TxMode.FREE_TEXT - private var selectedSubmode: Int = SUBMODE_NORMAL - private var defaultSendButtonTint: ColorStateList? = null - private var modeOptions: List = emptyList() - private lateinit var queueAdapter: TransmitQueueAdapter - private var currentTxOffset: Float = 1500f - private var isApplyingDirected: Boolean = false - private var conversationCallsigns: List = emptyList() - private var heardCallsigns: List = emptyList() - - private val preferenceListener = - SharedPreferences.OnSharedPreferenceChangeListener { _, key -> - if (key == PREF_AUTOREPLY_ENABLED) { - updateModeOptions() - } - } - - private val broadcastReceiver = object : BroadcastReceiver() { - override fun onReceive(context: Context, intent: Intent) { - when (intent.action) { - JS8EngineService.ACTION_TX_STATE -> { - val state = intent.getStringExtra(JS8EngineService.EXTRA_TX_STATE) - handleTxState(state) - } - } - } - } - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View? { - return inflater.inflate(R.layout.fragment_transmit, container, false) - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - - // Initialize ViewModels - viewModel = ViewModelProvider(requireActivity())[TransmitViewModel::class.java] - decodeViewModel = ViewModelProvider(requireActivity())[DecodeViewModel::class.java] - - // Find views - messageEditText = view.findViewById(R.id.message_edit_text) - directedEditText = view.findViewById(R.id.directed_edit_text) - sendButton = view.findViewById(R.id.send_button) - queueRecyclerView = view.findViewById(R.id.queue_recycler_view) - queueEmptyText = view.findViewById(R.id.queue_empty_text) - txStatusText = view.findViewById(R.id.tx_status_text) - modeSelect = view.findViewById(R.id.tx_mode_select) - speedSelect = view.findViewById(R.id.tx_speed_select) - defaultSendButtonTint = sendButton.backgroundTintList - - queueAdapter = TransmitQueueAdapter() - queueRecyclerView.adapter = queueAdapter - - // Set up listeners - setupModeSelector() - setupSpeedSelector() - - // Observe ViewModel - observeViewModel() - - // Register broadcast receiver - registerBroadcastReceiver() - } - - override fun onViewStateRestored(savedInstanceState: Bundle?) { - super.onViewStateRestored(savedInstanceState) - // Text watchers must attach after view state restoration. Restoring the - // saved (stale) field text fires the watchers, which would push it into - // the ViewModel and wipe a directed callsign set from another screen. - setupListeners() - } - - override fun onDestroyView() { - super.onDestroyView() - unregisterBroadcastReceiver() - } - - override fun onStart() { - super.onStart() - PreferenceManager.getDefaultSharedPreferences(requireContext()) - .registerOnSharedPreferenceChangeListener(preferenceListener) - updateModeOptions() - } - - override fun onResume() { - super.onResume() - val directed = viewModel.directedTo.value.orEmpty() - val current = directedEditText.text?.toString().orEmpty() - if (directed.isNotBlank() && directed != current) { - isApplyingDirected = true - directedEditText.setText(directed, false) - directedEditText.setSelection(directed.length) - isApplyingDirected = false - } - } - - override fun onStop() { - PreferenceManager.getDefaultSharedPreferences(requireContext()) - .unregisterOnSharedPreferenceChangeListener(preferenceListener) - super.onStop() - } - - private fun setupListeners() { - // Message text changes - messageEditText.addTextChangedListener(object : TextWatcher { - override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {} - override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {} - override fun afterTextChanged(s: Editable?) { - viewModel.setComposedMessage(s?.toString() ?: "") - updateSendButtonState() - } - }) - - // Directed callsign changes - directedEditText.addTextChangedListener(object : TextWatcher { - override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {} - override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {} - override fun afterTextChanged(s: Editable?) { - if (isApplyingDirected) return - viewModel.setDirectedTo(s?.toString() ?: "") - } - }) - - // Send button - sendButton.setOnClickListener { - sendMessage() - } - } - - private fun setupModeSelector() { - modeSelect.setOnItemClickListener { _, _, position, _ -> - selectedMode = modeOptions.getOrNull(position)?.mode ?: TxMode.FREE_TEXT - updateSendButtonState() - } - } - - private fun updateModeOptions() { - modeOptions = listOf( - ModeOption(getString(R.string.tx_mode_free_text), TxMode.FREE_TEXT), - ModeOption(getString(R.string.tx_mode_cq), TxMode.CQ), - ModeOption(heartbeatLabel(), TxMode.HEARTBEAT) - ) - - val labels = modeOptions.map { it.label } - modeSelect.setAdapter( - NoFilterArrayAdapter( - requireContext(), - android.R.layout.simple_list_item_1, - labels - ) - ) - - val selectedIndex = modeOptions.indexOfFirst { it.mode == selectedMode } - .takeIf { it >= 0 } ?: 0 - modeSelect.setText(modeOptions[selectedIndex].label, false) - selectedMode = modeOptions[selectedIndex].mode - updateSendButtonState() - } - - private fun heartbeatLabel(): String { - return if (isAutoreplyEnabled()) { - getString(R.string.tx_mode_hb_ack) - } else { - getString(R.string.tx_mode_heartbeat) - } - } - - private fun isAutoreplyEnabled(): Boolean { - val prefs = PreferenceManager.getDefaultSharedPreferences(requireContext()) - return prefs.getBoolean(PREF_AUTOREPLY_ENABLED, false) - } - - private fun setupSpeedSelector() { - val prefs = PreferenceManager.getDefaultSharedPreferences(requireContext()) - val speedOptions = listOf( - SpeedOption(getString(R.string.tx_speed_slow), SUBMODE_SLOW), - SpeedOption(getString(R.string.tx_speed_normal), SUBMODE_NORMAL), - SpeedOption(getString(R.string.tx_speed_fast), SUBMODE_FAST), - SpeedOption(getString(R.string.tx_speed_turbo), SUBMODE_TURBO) - ) - - val adapter = NoFilterArrayAdapter( - requireContext(), - android.R.layout.simple_list_item_1, - speedOptions.map { it.label } - ) - speedSelect.setAdapter(adapter) - - val savedSubmode = prefs.getInt(PREF_TX_SUBMODE, SUBMODE_NORMAL) - val defaultIndex = speedOptions.indexOfFirst { it.submode == savedSubmode } - .takeIf { it >= 0 } ?: 0 - speedSelect.setText(speedOptions[defaultIndex].label, false) - selectedSubmode = speedOptions[defaultIndex].submode - - speedSelect.setOnItemClickListener { _, _, position, _ -> - selectedSubmode = speedOptions.getOrNull(position)?.submode ?: SUBMODE_NORMAL - prefs.edit().putInt(PREF_TX_SUBMODE, selectedSubmode).apply() - } - } - - private fun registerBroadcastReceiver() { - val filter = IntentFilter().apply { - addAction(JS8EngineService.ACTION_TX_STATE) - } - LocalBroadcastManager.getInstance(requireContext()) - .registerReceiver(broadcastReceiver, filter) - } - - private fun unregisterBroadcastReceiver() { - LocalBroadcastManager.getInstance(requireContext()) - .unregisterReceiver(broadcastReceiver) - } - - private fun handleTxState(state: String?) { - // TX state updates are handled centrally by MainActivity - // This receiver is kept for UI updates that observe the ViewModel - } - - private fun observeViewModel() { - // Observe composed message - viewModel.composedMessage.observe(viewLifecycleOwner) { text -> - if (text.isEmpty() && messageEditText.text?.toString()?.isNotEmpty() == true) { - messageEditText.text?.clear() - } - } - - viewModel.directedTo.observe(viewLifecycleOwner) { callsign -> - val current = directedEditText.text?.toString().orEmpty() - if (callsign.isNotBlank() && callsign != current) { - isApplyingDirected = true - directedEditText.setText(callsign, false) - directedEditText.setSelection(callsign.length) - isApplyingDirected = false - } - } - - // Callsign suggestions: conversation history plus stations heard this session - MessageRepository.getInstance(requireContext()).getConversationCallsigns() - .observe(viewLifecycleOwner) { callsigns -> - conversationCallsigns = callsigns - updateCallsignSuggestions() - } - - decodeViewModel.decodes.observe(viewLifecycleOwner) { - heardCallsigns = decodeViewModel.heardCallsigns() - updateCallsignSuggestions() - } - - viewModel.txOffsetHz.observe(viewLifecycleOwner) { offset -> - currentTxOffset = offset - updateTxStatusText(viewModel.txState.value) - } - - // Observe TX state - viewModel.txState.observe(viewLifecycleOwner) { state -> - val safeState = state ?: TransmitState.IDLE - updateTxStatusText(safeState) - updateSendButtonTint(safeState) - } - - // Observe queue - viewModel.queue.observe(viewLifecycleOwner) { queue -> - queueAdapter.submitList(queue) - if (queue.isEmpty()) { - queueRecyclerView.visibility = View.GONE - queueEmptyText.visibility = View.VISIBLE - } else { - queueRecyclerView.visibility = View.VISIBLE - queueEmptyText.visibility = View.GONE - } - } - } - - private fun updateCallsignSuggestions() { - val merged = (conversationCallsigns + heardCallsigns).distinct() - directedEditText.setAdapter( - ArrayAdapter(requireContext(), android.R.layout.simple_list_item_1, merged) - ) - } - - private fun updateSendButtonState() { - val hasText = messageEditText.text?.isNotBlank() == true - sendButton.isEnabled = selectedMode != TxMode.FREE_TEXT || hasText - } - - private fun updateTxStatusText(state: TransmitState?) { - val safeState = state ?: TransmitState.IDLE - txStatusText.text = when (safeState) { - TransmitState.IDLE -> "Ready to transmit at ${currentTxOffset.toInt()} Hz" - TransmitState.QUEUED -> getString(R.string.tx_status_queued) - TransmitState.TRANSMITTING -> getString(R.string.tx_status_transmitting) - } - } - - private fun updateSendButtonTint(state: TransmitState) { - when (state) { - TransmitState.IDLE -> sendButton.backgroundTintList = defaultSendButtonTint - TransmitState.QUEUED -> { - val color = ContextCompat.getColor(requireContext(), R.color.tx_button_queued) - sendButton.backgroundTintList = ColorStateList.valueOf(color) - } - TransmitState.TRANSMITTING -> { - val color = ContextCompat.getColor(requireContext(), R.color.tx_button_transmitting) - sendButton.backgroundTintList = ColorStateList.valueOf(color) - } - } - } - - private fun sendMessage() { - if (!hasCallsignConfigured()) { - Snackbar.make( - requireView(), - R.string.error_callsign_required, - Snackbar.LENGTH_LONG - ).show() - return - } - val text = messageEditText.text?.toString()?.trim().orEmpty() - val (payloadText, directed) = when (selectedMode) { - TxMode.FREE_TEXT -> { - if (text.isEmpty()) { - Snackbar.make(requireView(), "Enter a message", Snackbar.LENGTH_SHORT).show() - return - } - val target = directedEditText.text?.toString()?.trim()?.takeIf { it.isNotEmpty() } - text to target - } - TxMode.CQ -> "CQ CQ CQ" to null - TxMode.HEARTBEAT -> "HB" to null - } - - // Queue the message - MainActivity will handle sending to service - viewModel.queueMessage(payloadText, directed) - - // Broadcast to trigger queue processing (MainActivity handles this) - LocalBroadcastManager.getInstance(requireContext()).sendBroadcast( - Intent(com.js8call.example.MainActivity.ACTION_PROCESS_TX_QUEUE) - ) - - // Show confirmation - val message = if (directed != null) { - "Message queued for $directed" - } else { - "Message queued" - } - Snackbar.make(requireView(), message, Snackbar.LENGTH_SHORT).show() - } - - private fun hasCallsignConfigured(): Boolean { - val prefs = PreferenceManager.getDefaultSharedPreferences(requireContext()) - val callsign = prefs.getString("callsign", "")?.trim().orEmpty() - return callsign.isNotBlank() - } - - private data class SpeedOption(val label: String, val submode: Int) - private data class ModeOption(val label: String, val mode: TxMode) - - private enum class TxMode { - FREE_TEXT, - CQ, - HEARTBEAT - } - - private class NoFilterArrayAdapter( - context: android.content.Context, - layoutResId: Int, - private val items: List - ) : ArrayAdapter(context, layoutResId, items) { - private val noFilter = object : Filter() { - override fun performFiltering(constraint: CharSequence?): FilterResults { - return FilterResults().apply { - values = items - count = items.size - } - } - - override fun publishResults(constraint: CharSequence?, results: FilterResults?) { - notifyDataSetChanged() - } - } - - override fun getFilter(): Filter = noFilter - } - - companion object { - private const val SUBMODE_NORMAL = 0 - private const val SUBMODE_FAST = 1 - private const val SUBMODE_TURBO = 2 - private const val SUBMODE_SLOW = 4 - private const val PREF_AUTOREPLY_ENABLED = "autoreply_enabled" - private const val PREF_TX_SUBMODE = "tx_submode" - } -} diff --git a/android/app/src/main/java/com/js8call/example/ui/TransmitQueueAdapter.kt b/android/app/src/main/java/com/js8call/example/ui/TransmitQueueAdapter.kt deleted file mode 100644 index dc2c14a8d..000000000 --- a/android/app/src/main/java/com/js8call/example/ui/TransmitQueueAdapter.kt +++ /dev/null @@ -1,64 +0,0 @@ -package com.js8call.example.ui - -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.widget.TextView -import androidx.recyclerview.widget.DiffUtil -import androidx.recyclerview.widget.ListAdapter -import androidx.recyclerview.widget.RecyclerView -import com.js8call.example.R -import com.js8call.example.model.TransmitMessage -import com.js8call.example.util.TxMessageClassifier - -class TransmitQueueAdapter : - ListAdapter(QueueDiffCallback()) { - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): QueueViewHolder { - val view = LayoutInflater.from(parent.context) - .inflate(android.R.layout.simple_list_item_2, parent, false) - return QueueViewHolder(view) - } - - override fun onBindViewHolder(holder: QueueViewHolder, position: Int) { - holder.bind(getItem(position)) - } - - class QueueViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { - private val line1: TextView = itemView.findViewById(android.R.id.text1) - private val line2: TextView = itemView.findViewById(android.R.id.text2) - - fun bind(message: TransmitMessage) { - line1.text = formatQueueText(message) - val context = itemView.context - line2.text = if (message.directed.isNullOrBlank()) { - context.getString(R.string.tx_queue_broadcast) - } else { - context.getString(R.string.tx_queue_to, message.directed) - } - } - - private fun formatQueueText(message: TransmitMessage): String { - val directed = message.directed?.trim().orEmpty() - val trimmed = message.text.trim() - if (directed.isEmpty()) return trimmed - if (trimmed.startsWith("`")) return trimmed - - if (TxMessageClassifier.isBaseMessage(trimmed)) return trimmed - if (trimmed.startsWith(directed, ignoreCase = true)) return trimmed - - val sep = if (trimmed.startsWith(" ")) "" else " " - return directed + sep + trimmed - } - } - - private class QueueDiffCallback : DiffUtil.ItemCallback() { - override fun areItemsTheSame(oldItem: TransmitMessage, newItem: TransmitMessage): Boolean { - return oldItem.timestamp == newItem.timestamp && oldItem.text == newItem.text - } - - override fun areContentsTheSame(oldItem: TransmitMessage, newItem: TransmitMessage): Boolean { - return oldItem == newItem - } - } -} diff --git a/android/app/src/main/java/com/js8call/example/ui/TransmitViewModel.kt b/android/app/src/main/java/com/js8call/example/ui/TransmitViewModel.kt index b6b65c47b..ada5f1bb0 100644 --- a/android/app/src/main/java/com/js8call/example/ui/TransmitViewModel.kt +++ b/android/app/src/main/java/com/js8call/example/ui/TransmitViewModel.kt @@ -1,15 +1,18 @@ package com.js8call.example.ui import android.app.Application +import android.os.Handler +import android.os.Looper +import android.os.SystemClock import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData +import androidx.preference.PreferenceManager import com.js8call.example.model.TransmitMessage import com.js8call.example.model.TransmitState /** - * ViewModel for the Transmit screen. - * Manages message composition and TX queue. + * ViewModel for the TX queue and transmit state. */ class TransmitViewModel(application: Application) : AndroidViewModel(application) { @@ -19,56 +22,64 @@ class TransmitViewModel(application: Application) : AndroidViewModel(application private val _queue = MutableLiveData>(emptyList()) val queue: LiveData> = _queue - private val _composedMessage = MutableLiveData("") - val composedMessage: LiveData = _composedMessage - - private val _directedTo = MutableLiveData("") - val directedTo: LiveData = _directedTo - private val _txOffsetHz = MutableLiveData(1500f) val txOffsetHz: LiveData = _txOffsetHz + // Seconds left in the current TX frame, or null when not transmitting. + // The frame period comes from the selected mode (Slow/Normal/Fast/Turbo). + private val _txCountdownSeconds = MutableLiveData(null) + val txCountdownSeconds: LiveData = _txCountdownSeconds + + // (current frame, total frames) of the transmission in progress, or null when idle. + private val _txFrameProgress = MutableLiveData?>(null) + val txFrameProgress: LiveData?> = _txFrameProgress + private val txQueue = mutableListOf() + private val countdownHandler = Handler(Looper.getMainLooper()) + private var txStartedAt = 0L + private val countdownRunnable = object : Runnable { + override fun run() { + val periodMs = framePeriodMs() + val elapsed = SystemClock.elapsedRealtime() - txStartedAt + // A message can span several frames; the countdown restarts each frame. + val leftMs = periodMs - (elapsed % periodMs) + _txCountdownSeconds.value = ((leftMs + 999) / 1000).toInt() + countdownHandler.postDelayed(this, 1000) + } + } + /** * Queue a message for transmission. - * @param clearComposed If true, clears the composed message field (for user-initiated sends) */ - fun queueMessage(text: String, directed: String? = null, priority: Int = 0, clearComposed: Boolean = true) { + fun queueMessage( + text: String, + directed: String? = null, + priority: Int = 0, + dbId: Long? = null, + mailboxId: Long? = null, + mailboxRecipient: String? = null + ) { if (text.isBlank()) return val message = TransmitMessage( text = text.trim(), directed = directed?.takeIf { it.isNotBlank() }, - priority = priority + priority = priority, + dbId = dbId, + mailboxId = mailboxId, + mailboxRecipient = mailboxRecipient ) txQueue.add(message) txQueue.sortByDescending { it.priority } _queue.value = txQueue.toList() - _txState.value = TransmitState.QUEUED - - // Clear composed message after queuing (only for user-initiated sends) - if (clearComposed) { - _composedMessage.value = "" + if (_txState.value != TransmitState.TRANSMITTING) { + _txState.value = TransmitState.QUEUED } } - /** - * Set composed message text. - */ - fun setComposedMessage(text: String) { - _composedMessage.value = text - } - - /** - * Set directed callsign. - */ - fun setDirectedTo(callsign: String) { - _directedTo.value = callsign.uppercase() - } - /** * Set the TX offset frequency in Hz. */ @@ -83,20 +94,6 @@ class TransmitViewModel(application: Application) : AndroidViewModel(application return _txOffsetHz.value ?: 1500f } - /** - * Send CQ. - */ - fun sendCQ() { - queueMessage("CQ CQ CQ", priority = 1) - } - - /** - * Send SNR report to specific station. - */ - fun sendSnrReport(callsign: String, snr: Int) { - queueMessage("$callsign SNR $snr", directed = callsign, priority = 2) - } - /** * Remove message from queue. */ @@ -116,6 +113,7 @@ class TransmitViewModel(application: Application) : AndroidViewModel(application txQueue.clear() _queue.value = emptyList() _txState.value = TransmitState.IDLE + stopCountdown() } /** @@ -123,20 +121,37 @@ class TransmitViewModel(application: Application) : AndroidViewModel(application */ fun startTransmitting() { _txState.value = TransmitState.TRANSMITTING + startCountdown() } fun setQueued() { _txState.value = TransmitState.QUEUED } + /** + * Frame progress from the engine. The index advances when the engine + * queues the next frame, ~2s before its audio starts; the countdown + * restarts only on the audio-start edge (TX_STATE_STARTED), so it runs + * one full cycle per frame without a mid-gap reset. + */ + fun setTxProgress(frameIndex: Int, frameCount: Int) { + _txFrameProgress.value = if (frameIndex > 0 && frameCount > 0) { + frameIndex to frameCount + } else { + null + } + } + /** * Transmission complete (called when engine finishes TX). + * @return the message that finished, or null if the queue was empty. */ - fun transmissionComplete() { - // Remove first item from queue - if (txQueue.isNotEmpty()) { - txQueue.removeAt(0) - _queue.value = txQueue.toList() + fun transmissionComplete(): TransmitMessage? { + stopCountdown() + val finished = if (txQueue.isNotEmpty()) { + txQueue.removeAt(0).also { _queue.value = txQueue.toList() } + } else { + null } _txState.value = if (txQueue.isEmpty()) { @@ -144,14 +159,26 @@ class TransmitViewModel(application: Application) : AndroidViewModel(application } else { TransmitState.QUEUED } + return finished } - fun transmissionFailed() { + /** + * Transmission failed. + * @return the message that failed, or null if the queue was empty. + */ + fun transmissionFailed(): TransmitMessage? { + stopCountdown() + val failed = if (txQueue.isNotEmpty()) { + txQueue.removeAt(0).also { _queue.value = txQueue.toList() } + } else { + null + } _txState.value = if (txQueue.isEmpty()) { TransmitState.IDLE } else { TransmitState.QUEUED } + return failed } /** @@ -160,4 +187,39 @@ class TransmitViewModel(application: Application) : AndroidViewModel(application fun getNextMessage(): TransmitMessage? { return txQueue.firstOrNull() } + + private fun startCountdown() { + txStartedAt = SystemClock.elapsedRealtime() + countdownHandler.removeCallbacks(countdownRunnable) + countdownRunnable.run() + } + + private fun stopCountdown() { + countdownHandler.removeCallbacks(countdownRunnable) + _txCountdownSeconds.value = null + _txFrameProgress.value = null + } + + private fun framePeriodMs(): Long { + val prefs = PreferenceManager.getDefaultSharedPreferences(getApplication()) + return when (prefs.getInt(PREF_TX_SUBMODE, SUBMODE_NORMAL)) { + SUBMODE_SLOW -> 30000L + SUBMODE_FAST -> 10000L + SUBMODE_TURBO -> 6000L + else -> 15000L + } + } + + override fun onCleared() { + countdownHandler.removeCallbacks(countdownRunnable) + super.onCleared() + } + + companion object { + const val PREF_TX_SUBMODE = "tx_submode" + const val SUBMODE_NORMAL = 0 + const val SUBMODE_FAST = 1 + const val SUBMODE_TURBO = 2 + const val SUBMODE_SLOW = 4 + } } diff --git a/android/app/src/main/java/com/js8call/example/util/AvatarColor.kt b/android/app/src/main/java/com/js8call/example/util/AvatarColor.kt new file mode 100644 index 000000000..5a3f98d3f --- /dev/null +++ b/android/app/src/main/java/com/js8call/example/util/AvatarColor.kt @@ -0,0 +1,32 @@ +package com.js8call.example.util + +import com.js8call.example.R + +/** + * A colour per station, so a list can be read by shape rather than by + * reading every callsign. Derived from the callsign rather than stored, so + * a station keeps its colour across devices and database wipes, and a + * renamed station does not change colour underneath the operator. + */ +object AvatarColor { + + private val PALETTE = intArrayOf( + R.color.avatar_1, R.color.avatar_2, R.color.avatar_3, R.color.avatar_4, + R.color.avatar_5, R.color.avatar_6, R.color.avatar_7, R.color.avatar_8 + ) + + /** + * A colour resource for [callsign]. Uses its own hash rather than + * String.hashCode, which is stable across JVMs but not something to + * depend on for a value that has to look the same every run. + */ + fun forCallsign(callsign: String): Int { + val key = callsign.trim().uppercase() + if (key.isEmpty()) return PALETTE[0] + var hash = 0 + for (c in key) { + hash = (hash * 31 + c.code) and 0x7FFFFFFF + } + return PALETTE[hash % PALETTE.size] + } +} diff --git a/android/app/src/main/java/com/js8call/example/util/ContactSearch.kt b/android/app/src/main/java/com/js8call/example/util/ContactSearch.kt new file mode 100644 index 000000000..45dca805b --- /dev/null +++ b/android/app/src/main/java/com/js8call/example/util/ContactSearch.kt @@ -0,0 +1,31 @@ +package com.js8call.example.util + +import com.js8call.example.data.ContactEntity + +/** + * Filtering for the contacts list. The list grows on its own every time a + * station is decoded, so it needs a way to find one station without + * scrolling past every station the radio has ever heard. + */ +object ContactSearch { + + /** + * Match [query] against everything that identifies a station: callsign, + * name, grid, the operator's own notes, and the INFO the station sent. + * A blank query matches everything, so the unfiltered list is the same + * code path as the filtered one. + */ + fun filter(contacts: List, query: String): List { + val needle = query.trim() + if (needle.isEmpty()) return contacts + return contacts.filter { matches(it, needle) } + } + + fun matches(contact: ContactEntity, query: String): Boolean { + val needle = query.trim() + if (needle.isEmpty()) return true + return sequenceOf( + contact.callsign, contact.name, contact.grid, contact.comment, contact.info + ).any { it != null && it.contains(needle, ignoreCase = true) } + } +} diff --git a/android/app/src/main/java/com/js8call/example/util/DisplayName.kt b/android/app/src/main/java/com/js8call/example/util/DisplayName.kt new file mode 100644 index 000000000..db8c75336 --- /dev/null +++ b/android/app/src/main/java/com/js8call/example/util/DisplayName.kt @@ -0,0 +1,31 @@ +package com.js8call.example.util + +/** + * What a station is called on screen. One place decides, so a list row, a + * thread toolbar and a notification never disagree. + * + * A named station leads with its name and keeps the callsign underneath, + * because the callsign is the identity on the air and has to stay visible. + * An unnamed one is just its callsign, with nothing underneath. + */ +object DisplayName { + + /** The headline: the name when there is one, otherwise the callsign. */ + fun of(callsign: String, name: String?): String { + val trimmed = name?.trim() + return if (trimmed.isNullOrEmpty()) callsign else trimmed + } + + /** The second line: the callsign, or null when it is already the headline. */ + fun secondary(callsign: String, name: String?): String? { + val trimmed = name?.trim() + return if (trimmed.isNullOrEmpty()) null else callsign + } + + /** The letter for the avatar circle, taken from whatever leads. */ + fun initial(callsign: String, name: String?): String { + val headline = of(callsign, name) + val letter = headline.firstOrNull { it.isLetterOrDigit() } ?: return "?" + return letter.uppercase() + } +} diff --git a/android/app/src/main/java/com/js8call/example/util/Js8Commands.kt b/android/app/src/main/java/com/js8call/example/util/Js8Commands.kt new file mode 100644 index 000000000..df4074e60 --- /dev/null +++ b/android/app/src/main/java/com/js8call/example/util/Js8Commands.kt @@ -0,0 +1,116 @@ +package com.js8call.example.util + +/** + * The JS8 directed command vocabulary. + * + * This mirrors kDirectedCmds in core/src/protocol/varicode.cpp, which is the + * table the native layer packs and unpacks against. Js8CommandsTest reads that + * file and fails when the two drift apart, so the copy stays honest without a + * JNI crossing on the decode path. + * + * Names are stored without the leading space the native table carries. + */ +object Js8Commands { + + const val CMD_MSG = "MSG" + const val CMD_MSG_TO = "MSG TO:" + const val CMD_QUERY = "QUERY" + const val CMD_QUERY_MSGS = "QUERY MSGS" + const val CMD_QUERY_CALL = "QUERY CALL" + const val CMD_ACK = "ACK" + const val CMD_NACK = "NACK" + const val CMD_YES = "YES" + const val CMD_NO = "NO" + + /** Command name to the number the protocol packs it as. */ + val COMMANDS: Map = mapOf( + "HEARTBEAT" to -1, + "HB" to -1, + "CQ" to -1, + "SNR?" to 0, + "?" to 0, + "DIT DIT" to 1, + "NACK" to 2, + "HEARING?" to 3, + "GRID?" to 4, + ">" to 5, + "STATUS?" to 6, + "STATUS" to 7, + "HEARING" to 8, + "MSG" to 9, + "MSG TO:" to 10, + "QUERY" to 11, + "QUERY MSGS" to 12, + "QUERY MSGS?" to 12, + "QUERY CALL" to 13, + "ACK" to 14, + "GRID" to 15, + "INFO?" to 16, + "INFO" to 17, + "FB" to 18, + "HW CPY?" to 19, + "SK" to 20, + "RR" to 21, + "QSL?" to 22, + "QSL" to 23, + "CMD" to 24, + "SNR" to 25, + "NO" to 26, + "YES" to 27, + "73" to 28, + "HEARTBEAT SNR" to 29, + "AGN?" to 30 + ) + + /** + * Commands whose payload spans data frames, so the receiver has to buffer + * until the last-frame bit before the text is complete. + */ + val BUFFERED: Set = setOf(5, 9, 10, 11, 12, 13, 15, 24) + + /** Commands that carry a trailing checksum, and its width in bits. */ + val CHECKSUMMED: Map = mapOf( + 5 to 16, 9 to 16, 10 to 16, 11 to 16, 12 to 16, 13 to 16, 15 to 0, 24 to 16 + ) + + /** Longest name first, so QUERY MSGS wins over QUERY and MSG TO: over MSG. */ + private val byLongestName: List = + COMMANDS.keys.sortedByDescending { it.length } + + data class Match(val command: String, val payload: String) + + /** + * Match a command at the head of [remainder], which is everything after the + * addressed callsign. + * + * Matching runs against the raw string rather than tokens because two of + * these names contain a space, and because MSG TO: is written both as + * "MSG TO:KN4CRD" and as "MSG TO: KN4CRD" depending on who composed it. + * Returns null when nothing matches, leaving the caller to decide. + */ + fun matchAt(remainder: String): Match? { + val text = remainder.trimStart() + if (text.isEmpty()) return null + + for (name in byLongestName) { + if (!text.regionMatches(0, name, 0, name.length, ignoreCase = true)) continue + + // A name ending in ':' may be followed immediately by its argument. + // Every other name has to end on a token boundary, so that MSG does + // not match the front of MSGS. + if (!name.endsWith(":")) { + val next = text.getOrNull(name.length) + if (next != null && !next.isWhitespace()) continue + } + + return Match(name, text.substring(name.length).trim()) + } + return null + } + + fun isBuffered(command: String): Boolean = + COMMANDS[command.uppercase()]?.let { BUFFERED.contains(it) } == true + + fun isChecksummed(command: String): Boolean = + COMMANDS[command.uppercase()]?.let { CHECKSUMMED.containsKey(it) } == true +} diff --git a/android/app/src/main/java/com/js8call/example/util/Js8Groups.kt b/android/app/src/main/java/com/js8call/example/util/Js8Groups.kt new file mode 100644 index 000000000..dbc10346e --- /dev/null +++ b/android/app/src/main/java/com/js8call/example/util/Js8Groups.kt @@ -0,0 +1,32 @@ +package com.js8call.example.util + +/** + * The well-known JS8 group destinations, mirroring kBaseCalls in + * core/src/protocol/varicode.cpp minus the "<....>" sentinel. + * Js8GroupsTest reads that file and fails when the two drift apart. + */ +object Js8Groups { + + val WELL_KNOWN: List = listOf( + "@ALLCALL", "@JS8NET", + "@DX/NA", "@DX/SA", "@DX/EU", "@DX/AS", "@DX/AF", "@DX/OC", "@DX/AN", + "@REGION/1", "@REGION/2", "@REGION/3", + "@GROUP/0", "@GROUP/1", "@GROUP/2", "@GROUP/3", "@GROUP/4", + "@GROUP/5", "@GROUP/6", "@GROUP/7", "@GROUP/8", "@GROUP/9", + "@COMMAND", "@CONTROL", "@NET", "@NTS", + "@RESERVE/0", "@RESERVE/1", "@RESERVE/2", "@RESERVE/3", "@RESERVE/4", + "@APRSIS", "@RAGCHEW", "@JS8", "@EMCOMM", "@ARES", "@MARS", "@AMRRON", + "@RACES", "@RAYNET", "@RADAR", "@SKYWARN", "@CQ", "@HB", "@QSO", + "@QSOPARTY", "@CONTEST", "@FIELDDAY", "@SOTA", "@IOTA", "@POTA", + "@QRP", "@QRO" + ) + + /** + * The well-known groups worth suggesting as message targets. The + * protocol infrastructure addresses stay out of the picker. + */ + val SUGGESTED: List = WELL_KNOWN.filterNot { + it in setOf("@COMMAND", "@CONTROL", "@APRSIS") || + it.startsWith("@RESERVE/") + } +} diff --git a/android/app/src/main/java/com/js8call/example/util/LinkEvidence.kt b/android/app/src/main/java/com/js8call/example/util/LinkEvidence.kt new file mode 100644 index 000000000..551f18d8c --- /dev/null +++ b/android/app/src/main/java/com/js8call/example/util/LinkEvidence.kt @@ -0,0 +1,122 @@ +package com.js8call.example.util + +import java.util.Locale + +/** + * Mines decoded traffic for who-hears-whom evidence, the raw material of the + * network map. Everything here is inference from frames that were mostly not + * addressed to us: + * + * - any decode proves we hear its sender, at the SNR we measured + * - "N0DEF: KA0XYZ HEARTBEAT SNR +05" proves N0DEF hears KA0XYZ at +05 + * - "N0DEF: KA0XYZ SNR -12" proves the same, from an SNR? exchange + * - "N0DEF: KA0XYZ HEARING W1AW K5ABC" proves N0DEF hears both, no number + * - "N0DEF: KA0XYZ ACK" proves N0DEF received something from KA0XYZ + * - a forwarded relay frame's *DE* trail proves each hop heard the one before + * + * Links are directed. A hearing B says nothing about B hearing A, and on HF + * the reverse direction routinely differs. + */ +object LinkEvidence { + + /** + * What kind of frame produced the observation. Kept on the row so the map + * and the path recommender can weight a checksummed relay transfer above a + * bare HEARING mention. + */ + enum class Source { DECODE, HB_ACK, SNR_REPORT, HEARING, ACK, RELAY } + + /** [reporter] heard [heard], optionally at [snr] dB. */ + data class Observation( + val reporter: String, + val heard: String, + val snr: Int?, + val source: Source + ) + + // "N0DEF: rest" — the transmitting station and everything after it. + private val senderRegex = Regex("^\\s*([A-Z0-9/]+):\\s+(.*)$") + + // The "*DE* CALL" trail a forwarded relay frame accumulates, one entry + // per hop, each naming the station that hop received from. + private val relayTrailRegex = Regex("\\s(?:\\*DE\\*|VIA)\\s([A-Z0-9/]+)", RegexOption.IGNORE_CASE) + + // formatSNR output: "+05", "-12". Anything else is not a report. + private val snrRegex = Regex("^([+-]\\d{1,2})\\b") + + // HEARING lists are short (the desktop and this app both cap near 4), so + // anything past this is a garbled frame, not a longer list. + private const val MAX_HEARING_CALLS = 8 + + /** + * Evidence in one decoded frame. [decodeSnr] is what our own receiver + * measured for the frame's sender. + * + * Relay (`>`) frames only yield the we-hear-the-sender row here; their + * *DE* trail spans data frames and is mined by [fromRelayChain] after + * reassembly. + */ + fun fromDecode(myCallsign: String, text: String, decodeSnr: Int): List { + val my = myCallsign.trim().uppercase(Locale.US) + if (my.isEmpty()) return emptyList() + + val match = senderRegex.find(text.trim().uppercase(Locale.US)) ?: return emptyList() + val from = match.groupValues[1] + val rest = match.groupValues[2].trim() + if (!CallsignValidator.isAmateurCallsign(from) || from == my) return emptyList() + + val observations = mutableListOf() + observations.add(Observation(my, from, decodeSnr, Source.DECODE)) + + val tokens = rest.split(Regex("\\s+")) + val to = tokens.firstOrNull().orEmpty() + if (to.contains(">")) return observations + + val commandMatch = Js8Commands.matchAt(tokens.drop(1).joinToString(" ")) + ?: return observations + val toIsStation = CallsignValidator.isAmateurCallsign(to) && to != from + + when (commandMatch.command.uppercase(Locale.US)) { + "HEARTBEAT SNR" -> if (toIsStation) { + observations.add(Observation(from, to, parseSnr(commandMatch.payload), Source.HB_ACK)) + } + "SNR" -> if (toIsStation) { + observations.add(Observation(from, to, parseSnr(commandMatch.payload), Source.SNR_REPORT)) + } + "ACK" -> if (toIsStation) { + observations.add(Observation(from, to, null, Source.ACK)) + } + "HEARING" -> { + commandMatch.payload.split(Regex("\\s+")) + .take(MAX_HEARING_CALLS) + .filter { CallsignValidator.isAmateurCallsign(it) && it != from } + .forEach { observations.add(Observation(from, it, null, Source.HEARING)) } + } + } + return observations + } + + /** + * Evidence in a reassembled relay payload. Each hop appended the station + * it received from, so the trail plus the transmitting station form a + * hearing sequence: in "... *DE* A *DE* B" transmitted by C, B heard A + * and C heard B. + */ + fun fromRelayChain(transmitter: String, payload: String): List { + val trail = relayTrailRegex.findAll(payload.uppercase(Locale.US)) + .map { it.groupValues[1] } + .filter { CallsignValidator.isAmateurCallsign(it) } + .toMutableList() + val tx = transmitter.trim().uppercase(Locale.US) + if (CallsignValidator.isAmateurCallsign(tx)) trail.add(tx) + + return trail.zipWithNext() + .filter { (heard, reporter) -> heard != reporter } + .map { (heard, reporter) -> Observation(reporter, heard, null, Source.RELAY) } + } + + private fun parseSnr(payload: String): Int? { + val value = snrRegex.find(payload.trim())?.groupValues?.get(1)?.toIntOrNull() ?: return null + return if (value in -40..40) value else null + } +} diff --git a/android/app/src/main/java/com/js8call/example/util/Maidenhead.kt b/android/app/src/main/java/com/js8call/example/util/Maidenhead.kt new file mode 100644 index 000000000..2ee53aeb9 --- /dev/null +++ b/android/app/src/main/java/com/js8call/example/util/Maidenhead.kt @@ -0,0 +1,159 @@ +package com.js8call.example.util + +import java.util.Locale +import kotlin.math.atan2 +import kotlin.math.cos +import kotlin.math.roundToInt +import kotlin.math.sin +import kotlin.math.sqrt + +/** + * Maidenhead grid math: locator to coordinates, coordinates to locator, and + * great-circle distance and bearing between two locators. + * + * Coordinates for a locator are the center of the square it names, so a + * 4-character grid carries roughly ±50 km of uncertainty and the numbers + * derived from one are estimates, not measurements. Haversine distance is + * used rather than an ellipsoid model; the difference is under half a + * percent, far inside what the grid resolution already costs. + */ +object Maidenhead { + + data class LatLon(val lat: Double, val lon: Double) + + private const val EARTH_RADIUS_KM = 6371.0 + const val KM_PER_MILE = 1.609344 + + /** + * A standard locator: 2, 4, 6, or 8 characters of valid pairs. + * Case-insensitive, matching the desktop, which accepts the lower case + * the older QRA convention used. + */ + fun isValid(grid: String): Boolean { + val g = grid.trim().uppercase(Locale.US) + if (g.length !in setOf(2, 4, 6, 8)) return false + return g.withIndex().all { (i, c) -> + when (i) { + 0, 1 -> c in 'A'..'R' + 2, 3, 6, 7 -> c in '0'..'9' + else -> c in 'A'..'X' + } + } + } + + /** The center of the square the locator names, or null when invalid. */ + fun toLatLon(grid: String): LatLon? { + val g = grid.trim().uppercase(Locale.US) + if (!isValid(g)) return null + + var lon = (g[0] - 'A') * 20.0 - 180.0 + var lat = (g[1] - 'A') * 10.0 - 90.0 + var lonCell = 20.0 + var latCell = 10.0 + + if (g.length >= 4) { + lon += (g[2] - '0') * 2.0 + lat += (g[3] - '0') * 1.0 + lonCell = 2.0 + latCell = 1.0 + } + if (g.length >= 6) { + lon += (g[4] - 'A') * (5.0 / 60.0) + lat += (g[5] - 'A') * (2.5 / 60.0) + lonCell = 5.0 / 60.0 + latCell = 2.5 / 60.0 + } + if (g.length == 8) { + lon += (g[6] - '0') * (0.5 / 60.0) + lat += (g[7] - '0') * (0.25 / 60.0) + lonCell = 0.5 / 60.0 + latCell = 0.25 / 60.0 + } + + return LatLon(lat + latCell / 2.0, lon + lonCell / 2.0) + } + + /** The 8-character locator containing the coordinates. */ + fun fromLatLon(latitude: Double, longitude: Double): String { + var lon = longitude + var lat = latitude.coerceIn(-90.0, 90.0) + if (lon < -180.0) lon += 360.0 + if (lon > 180.0) lon -= 360.0 + if (lon == 180.0) lon = 179.999999 + if (lat == 90.0) lat = 89.999999 + + var lonRem = lon + 180.0 + var latRem = lat + 90.0 + + val lonField = (lonRem / 20.0).toInt().coerceIn(0, 17) + val latField = (latRem / 10.0).toInt().coerceIn(0, 17) + lonRem -= lonField * 20.0 + latRem -= latField * 10.0 + + val lonSquare = (lonRem / 2.0).toInt().coerceIn(0, 9) + val latSquare = latRem.toInt().coerceIn(0, 9) + lonRem -= lonSquare * 2.0 + latRem -= latSquare * 1.0 + + val lonSub = (lonRem / (5.0 / 60.0)).toInt().coerceIn(0, 23) + val latSub = (latRem / (2.5 / 60.0)).toInt().coerceIn(0, 23) + lonRem -= lonSub * (5.0 / 60.0) + latRem -= latSub * (2.5 / 60.0) + + val lonExt = (lonRem / (0.5 / 60.0)).toInt().coerceIn(0, 9) + val latExt = (latRem / (0.25 / 60.0)).toInt().coerceIn(0, 9) + + return buildString(8) { + append('A' + lonField) + append('A' + latField) + append('0' + lonSquare) + append('0' + latSquare) + append('A' + lonSub) + append('A' + latSub) + append('0' + lonExt) + append('0' + latExt) + } + } + + /** Great-circle distance between two points, in kilometers. */ + fun distanceKm(a: LatLon, b: LatLon): Double { + val dLat = Math.toRadians(b.lat - a.lat) + val dLon = Math.toRadians(b.lon - a.lon) + val h = sin(dLat / 2) * sin(dLat / 2) + + cos(Math.toRadians(a.lat)) * cos(Math.toRadians(b.lat)) * + sin(dLon / 2) * sin(dLon / 2) + return 2.0 * EARTH_RADIUS_KM * atan2(sqrt(h), sqrt(1.0 - h)) + } + + /** Initial great-circle bearing from [a] toward [b], 0..360 from north. */ + fun bearingDegrees(a: LatLon, b: LatLon): Double { + val lat1 = Math.toRadians(a.lat) + val lat2 = Math.toRadians(b.lat) + val dLon = Math.toRadians(b.lon - a.lon) + val y = sin(dLon) * cos(lat2) + val x = cos(lat1) * sin(lat2) - sin(lat1) * cos(lat2) * cos(dLon) + return (Math.toDegrees(atan2(y, x)) + 360.0) % 360.0 + } + + private val COMPASS = arrayOf("N", "NE", "E", "SE", "S", "SW", "W", "NW") + + /** The nearest of the eight compass points, matching the desktop's set. */ + fun compassPoint(bearing: Double): String = + COMPASS[((bearing % 360.0 + 360.0) % 360.0 / 45.0).roundToInt() % 8] + + /** + * Distance and bearing between two locators as one display string, in + * the operator's chosen unit: "770 mi · NE 83°". Null when either + * locator is invalid. + */ + fun describePath(fromGrid: String?, toGrid: String?, miles: Boolean): String? { + val a = toLatLon(fromGrid.orEmpty()) ?: return null + val b = toLatLon(toGrid.orEmpty()) ?: return null + val km = distanceKm(a, b) + val bearing = bearingDegrees(a, b) + val value = if (miles) km / KM_PER_MILE else km + val unit = if (miles) "mi" else "km" + val distText = String.format(Locale.US, "%,d", value.roundToInt()) + return "$distText $unit · ${compassPoint(bearing)} ${bearing.roundToInt()}°" + } +} diff --git a/android/app/src/main/java/com/js8call/example/util/NetworkGraph.kt b/android/app/src/main/java/com/js8call/example/util/NetworkGraph.kt new file mode 100644 index 000000000..8f7e34faa --- /dev/null +++ b/android/app/src/main/java/com/js8call/example/util/NetworkGraph.kt @@ -0,0 +1,73 @@ +package com.js8call.example.util + +import com.js8call.example.data.LinkObservationEntity +import java.util.Locale + +/** + * Aggregates raw link observations into the graph the network map draws: + * one node per station, one directed edge per (reporter, heard) pair. + * + * The edge keeps the most recent numbered SNR rather than an average, + * because on HF the current state of a path matters and an average blends + * in propagation that no longer exists. + */ +object NetworkGraph { + + /** [from] hears [to]. */ + data class Edge( + val from: String, + val to: String, + /** Most recent numbered SNR, or null when no observation carried one. */ + val snr: Int?, + val lastObservedAt: Long, + val observationCount: Int + ) + + data class Graph(val nodes: List, val edges: List) { + + /** Both directions of a pair confirmed, the strongest relay signal. */ + fun isBidirectional(a: String, b: String): Boolean = + edges.any { it.from == a && it.to == b } && + edges.any { it.from == b && it.to == a } + } + + fun build(observations: List, myCallsign: String): Graph { + val my = myCallsign.trim().uppercase(Locale.US) + + val byPair = observations + .filter { it.reporter != it.heard } + .groupBy { it.reporter to it.heard } + + val edges = byPair.map { (pair, group) -> + val latestNumbered = group.filter { it.snr != null }.maxByOrNull { it.observedAt } + Edge( + from = pair.first, + to = pair.second, + snr = latestNumbered?.snr, + lastObservedAt = group.maxOf { it.observedAt }, + observationCount = group.size + ) + }.sortedByDescending { it.lastObservedAt } + + val nodes = buildList { + if (my.isNotEmpty()) add(my) + edges.forEach { + if (it.from !in this) add(it.from) + if (it.to !in this) add(it.to) + } + } + + return Graph(nodes, edges) + } + + /** + * Edge strength on a 0..1 scale for drawing, from the JS8 SNR range. + * -28 is the decode floor; anything at or above 0 is a strong path. + * An edge with no number sits at the weak end rather than zero, since + * it is still a confirmed link. + */ + fun strength(snr: Int?): Float { + if (snr == null) return 0.25f + return ((snr + 28f) / 28f).coerceIn(0.15f, 1f) + } +} diff --git a/android/app/src/main/java/com/js8call/example/util/RelayPath.kt b/android/app/src/main/java/com/js8call/example/util/RelayPath.kt new file mode 100644 index 000000000..d90b35cbb --- /dev/null +++ b/android/app/src/main/java/com/js8call/example/util/RelayPath.kt @@ -0,0 +1,57 @@ +package com.js8call.example.util + +/** + * A relay path is an ordered list of stations that carry a message to its + * destination, nearest hop first. It is stored and transmitted in the `A>B` + * notation the JS8 relay command uses. + * + * On the wire the originator prepends `CALL>` once per hop, so a message to + * KN4CRD through KA0XYZ then N0DEF reads `KA0XYZ>N0DEF>KN4CRD HELLO` and is + * sent with no separate directed callsign. Each hop rewrites the head of the + * payload and appends its predecessor, and the destination recovers the whole + * return path from the trailing `*DE*` chain. + */ +object RelayPath { + + /** + * Every hop retransmits the whole message, so a three-frame message over + * two hops is three full transmissions. In Slow mode that runs to minutes + * of airtime. This is a guard rail, not a protocol limit. + */ + const val MAX_HOPS = 3 + + /** Read the stored `A>B` form into hops. Unknown or blank input is direct. */ + fun parse(stored: String?): List { + if (stored.isNullOrBlank()) return emptyList() + return stored.split(">") + .map { it.trim().uppercase() } + .filter { it.isNotEmpty() } + } + + /** Write hops back to the stored form. An empty path stores nothing. */ + fun format(hops: List): String? { + val clean = hops.map { it.trim().uppercase() }.filter { it.isNotEmpty() } + return if (clean.isEmpty()) null else clean.joinToString(">") + } + + /** + * Build the text to transmit. With no hops this is the plain body, which + * the caller sends with [destination] as the directed callsign. With hops + * the destination moves into the text, because the relay command carries + * it as payload rather than as the directed target. + */ + fun compose(hops: List, destination: String, text: String): String { + val clean = parse(format(hops)) + val dest = destination.trim().uppercase() + if (clean.isEmpty()) return text + return clean.joinToString(">", postfix = ">") + dest + " " + text + } + + /** + * The station a relayed message came from, given the return path a + * destination recovers from the `*DE*` chain. That path runs nearest hop + * first, so the originator is last. The nearest hop only handed it over, + * and threading or answering against it would credit the wrong station. + */ + fun originatorOfReturnPath(returnPath: String?): String? = parse(returnPath).lastOrNull() +} diff --git a/android/app/src/main/res/drawable/compose_input_background.xml b/android/app/src/main/res/drawable/compose_input_background.xml new file mode 100644 index 000000000..1879f4925 --- /dev/null +++ b/android/app/src/main/res/drawable/compose_input_background.xml @@ -0,0 +1,6 @@ + + + + + diff --git a/android/app/src/main/res/drawable/ic_add.xml b/android/app/src/main/res/drawable/ic_add.xml new file mode 100644 index 000000000..be14c4ee3 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_add.xml @@ -0,0 +1,10 @@ + + + + diff --git a/android/app/src/main/res/drawable/ic_alt_route.xml b/android/app/src/main/res/drawable/ic_alt_route.xml new file mode 100644 index 000000000..e3276af7f --- /dev/null +++ b/android/app/src/main/res/drawable/ic_alt_route.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/android/app/src/main/res/drawable/ic_arrow_drop_down.xml b/android/app/src/main/res/drawable/ic_arrow_drop_down.xml new file mode 100644 index 000000000..7ce4d8df9 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_arrow_drop_down.xml @@ -0,0 +1,10 @@ + + + + diff --git a/android/app/src/main/res/drawable/ic_bolt.xml b/android/app/src/main/res/drawable/ic_bolt.xml new file mode 100644 index 000000000..15d631364 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_bolt.xml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/android/app/src/main/res/drawable/ic_chat_bubble.xml b/android/app/src/main/res/drawable/ic_chat_bubble.xml new file mode 100644 index 000000000..016fd4142 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_chat_bubble.xml @@ -0,0 +1,13 @@ + + + + + + diff --git a/android/app/src/main/res/drawable/ic_chat_bubble_outline.xml b/android/app/src/main/res/drawable/ic_chat_bubble_outline.xml new file mode 100644 index 000000000..016fd4142 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_chat_bubble_outline.xml @@ -0,0 +1,13 @@ + + + + + + diff --git a/android/app/src/main/res/drawable/ic_check.xml b/android/app/src/main/res/drawable/ic_check.xml new file mode 100644 index 000000000..e25adf781 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_check.xml @@ -0,0 +1,10 @@ + + + + diff --git a/android/app/src/main/res/drawable/ic_close.xml b/android/app/src/main/res/drawable/ic_close.xml new file mode 100644 index 000000000..c75fa958e --- /dev/null +++ b/android/app/src/main/res/drawable/ic_close.xml @@ -0,0 +1,10 @@ + + + + diff --git a/android/app/src/main/res/drawable/ic_delete.xml b/android/app/src/main/res/drawable/ic_delete.xml new file mode 100644 index 000000000..1a9f6dd18 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_delete.xml @@ -0,0 +1,10 @@ + + + + diff --git a/android/app/src/main/res/drawable/ic_done_all.xml b/android/app/src/main/res/drawable/ic_done_all.xml new file mode 100644 index 000000000..274c6ca70 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_done_all.xml @@ -0,0 +1,10 @@ + + + + diff --git a/android/app/src/main/res/drawable/ic_drag_handle.xml b/android/app/src/main/res/drawable/ic_drag_handle.xml new file mode 100644 index 000000000..fa5b92d47 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_drag_handle.xml @@ -0,0 +1,10 @@ + + + + diff --git a/android/app/src/main/res/drawable/ic_error_outline.xml b/android/app/src/main/res/drawable/ic_error_outline.xml new file mode 100644 index 000000000..e87d5574f --- /dev/null +++ b/android/app/src/main/res/drawable/ic_error_outline.xml @@ -0,0 +1,10 @@ + + + + diff --git a/android/app/src/main/res/drawable/ic_graphic_eq.xml b/android/app/src/main/res/drawable/ic_graphic_eq.xml new file mode 100644 index 000000000..e143ae4fd --- /dev/null +++ b/android/app/src/main/res/drawable/ic_graphic_eq.xml @@ -0,0 +1,10 @@ + + + + diff --git a/android/app/src/main/res/drawable/ic_group.xml b/android/app/src/main/res/drawable/ic_group.xml new file mode 100644 index 000000000..46c91b4b0 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_group.xml @@ -0,0 +1,13 @@ + + + + + + diff --git a/android/app/src/main/res/drawable/ic_heart_plus.xml b/android/app/src/main/res/drawable/ic_heart_plus.xml new file mode 100644 index 000000000..4d1c86f06 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_heart_plus.xml @@ -0,0 +1,13 @@ + + + + + + diff --git a/android/app/src/main/res/drawable/ic_inbox.xml b/android/app/src/main/res/drawable/ic_inbox.xml new file mode 100644 index 000000000..c54d5977f --- /dev/null +++ b/android/app/src/main/res/drawable/ic_inbox.xml @@ -0,0 +1,14 @@ + + + + + + + diff --git a/android/app/src/main/res/drawable/ic_more_vert.xml b/android/app/src/main/res/drawable/ic_more_vert.xml new file mode 100644 index 000000000..78038f8f4 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_more_vert.xml @@ -0,0 +1,13 @@ + + + + + + diff --git a/android/app/src/main/res/drawable/ic_network_map.xml b/android/app/src/main/res/drawable/ic_network_map.xml new file mode 100644 index 000000000..315b156db --- /dev/null +++ b/android/app/src/main/res/drawable/ic_network_map.xml @@ -0,0 +1,27 @@ + + + + + + + + + diff --git a/android/app/src/main/res/drawable/ic_open_in_new.xml b/android/app/src/main/res/drawable/ic_open_in_new.xml new file mode 100644 index 000000000..2276b30d0 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_open_in_new.xml @@ -0,0 +1,13 @@ + + + + + + diff --git a/android/app/src/main/res/drawable/ic_pause.xml b/android/app/src/main/res/drawable/ic_pause.xml new file mode 100644 index 000000000..164e341b4 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_pause.xml @@ -0,0 +1,10 @@ + + + + diff --git a/android/app/src/main/res/drawable/ic_rss_feed.xml b/android/app/src/main/res/drawable/ic_rss_feed.xml new file mode 100644 index 000000000..9eca3d6b9 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_rss_feed.xml @@ -0,0 +1,10 @@ + + + + diff --git a/android/app/src/main/res/drawable/ic_schedule.xml b/android/app/src/main/res/drawable/ic_schedule.xml new file mode 100644 index 000000000..fd281f638 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_schedule.xml @@ -0,0 +1,10 @@ + + + + diff --git a/android/app/src/main/res/drawable/ic_search.xml b/android/app/src/main/res/drawable/ic_search.xml new file mode 100644 index 000000000..8846a59af --- /dev/null +++ b/android/app/src/main/res/drawable/ic_search.xml @@ -0,0 +1,10 @@ + + + + diff --git a/android/app/src/main/res/drawable/ic_send.xml b/android/app/src/main/res/drawable/ic_send.xml new file mode 100644 index 000000000..d1cfac11e --- /dev/null +++ b/android/app/src/main/res/drawable/ic_send.xml @@ -0,0 +1,10 @@ + + + + diff --git a/android/app/src/main/res/drawable/ic_settings.xml b/android/app/src/main/res/drawable/ic_settings.xml new file mode 100644 index 000000000..dd427aa90 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_settings.xml @@ -0,0 +1,10 @@ + + + + diff --git a/android/app/src/main/res/drawable/ic_star.xml b/android/app/src/main/res/drawable/ic_star.xml new file mode 100644 index 000000000..a0776dbce --- /dev/null +++ b/android/app/src/main/res/drawable/ic_star.xml @@ -0,0 +1,13 @@ + + + + + + diff --git a/android/app/src/main/res/drawable/ic_star_outline.xml b/android/app/src/main/res/drawable/ic_star_outline.xml new file mode 100644 index 000000000..caf16ff88 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_star_outline.xml @@ -0,0 +1,13 @@ + + + + + + diff --git a/android/app/src/main/res/drawable/ic_sync_alt.xml b/android/app/src/main/res/drawable/ic_sync_alt.xml new file mode 100644 index 000000000..1b62330fd --- /dev/null +++ b/android/app/src/main/res/drawable/ic_sync_alt.xml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/android/app/src/main/res/drawable/snr_dot.xml b/android/app/src/main/res/drawable/snr_dot.xml new file mode 100644 index 000000000..e6466cde5 --- /dev/null +++ b/android/app/src/main/res/drawable/snr_dot.xml @@ -0,0 +1,5 @@ + + + + diff --git a/android/app/src/main/res/drawable/status_dot.xml b/android/app/src/main/res/drawable/status_dot.xml new file mode 100644 index 000000000..987d75ec7 --- /dev/null +++ b/android/app/src/main/res/drawable/status_dot.xml @@ -0,0 +1,9 @@ + + + + + + diff --git a/android/app/src/main/res/layout-sw600dp-land/activity_main.xml b/android/app/src/main/res/layout-sw600dp-land/activity_main.xml new file mode 100644 index 000000000..ea00c528c --- /dev/null +++ b/android/app/src/main/res/layout-sw600dp-land/activity_main.xml @@ -0,0 +1,30 @@ + + + + + + + + diff --git a/android/app/src/main/res/layout-sw600dp-land/fragment_monitor.xml b/android/app/src/main/res/layout-sw600dp-land/fragment_monitor.xml new file mode 100644 index 000000000..8f3bf8a02 --- /dev/null +++ b/android/app/src/main/res/layout-sw600dp-land/fragment_monitor.xml @@ -0,0 +1,88 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/res/layout/activity_main.xml b/android/app/src/main/res/layout/activity_main.xml index 1e2de4c1f..549adc740 100644 --- a/android/app/src/main/res/layout/activity_main.xml +++ b/android/app/src/main/res/layout/activity_main.xml @@ -23,6 +23,7 @@ app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintLeft_toLeftOf="parent" app:layout_constraintRight_toRightOf="parent" + app:labelVisibilityMode="labeled" app:menu="@menu/bottom_nav_menu" /> diff --git a/android/app/src/main/res/layout/compose_bar.xml b/android/app/src/main/res/layout/compose_bar.xml new file mode 100644 index 000000000..842300c16 --- /dev/null +++ b/android/app/src/main/res/layout/compose_bar.xml @@ -0,0 +1,50 @@ + + + + + + + + + + + + diff --git a/android/app/src/main/res/layout/dialog_edit_comment.xml b/android/app/src/main/res/layout/dialog_edit_comment.xml new file mode 100644 index 000000000..10555a723 --- /dev/null +++ b/android/app/src/main/res/layout/dialog_edit_comment.xml @@ -0,0 +1,18 @@ + + + + + + diff --git a/android/app/src/main/res/layout/dialog_new_message.xml b/android/app/src/main/res/layout/dialog_new_message.xml new file mode 100644 index 000000000..1105a0e88 --- /dev/null +++ b/android/app/src/main/res/layout/dialog_new_message.xml @@ -0,0 +1,18 @@ + + + + + + diff --git a/android/app/src/main/res/layout/dialog_send_via_relay.xml b/android/app/src/main/res/layout/dialog_send_via_relay.xml new file mode 100644 index 000000000..0576a2f3e --- /dev/null +++ b/android/app/src/main/res/layout/dialog_send_via_relay.xml @@ -0,0 +1,40 @@ + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/res/layout/dialog_time_drift.xml b/android/app/src/main/res/layout/dialog_time_drift.xml new file mode 100644 index 000000000..ba03c7d17 --- /dev/null +++ b/android/app/src/main/res/layout/dialog_time_drift.xml @@ -0,0 +1,101 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/res/layout/fragment_contact_detail.xml b/android/app/src/main/res/layout/fragment_contact_detail.xml new file mode 100644 index 000000000..a2a2439b1 --- /dev/null +++ b/android/app/src/main/res/layout/fragment_contact_detail.xml @@ -0,0 +1,236 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/res/layout/fragment_contacts.xml b/android/app/src/main/res/layout/fragment_contacts.xml new file mode 100644 index 000000000..504f586dc --- /dev/null +++ b/android/app/src/main/res/layout/fragment_contacts.xml @@ -0,0 +1,106 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/res/layout/fragment_conversation.xml b/android/app/src/main/res/layout/fragment_conversation.xml index d3073c3c7..c9e3f89ab 100644 --- a/android/app/src/main/res/layout/fragment_conversation.xml +++ b/android/app/src/main/res/layout/fragment_conversation.xml @@ -1,115 +1,182 @@ - + android:background="?attr/colorSurface" + android:orientation="vertical"> - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + android:layout_height="wrap_content" + android:background="?attr/selectableItemBackground" + android:gravity="center_vertical" + android:minHeight="40dp" + android:orientation="horizontal" + android:paddingHorizontal="16dp"> + android:layout_weight="1" + android:ellipsize="end" + android:maxLines="1" + android:textAppearance="?attr/textAppearanceLabelMedium" + android:textColor="?attr/colorOnSurfaceVariant" + tools:text="2 relays · KA0XYZ › N0DEF" /> + + - - + - + + + android:layout_height="match_parent" + android:clipToPadding="false" + android:padding="8dp" + app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager" + app:stackFromEnd="true" + tools:listitem="@layout/item_message_incoming" /> - - - - + + - + - - + android:layout_marginTop="16dp" + android:text="@string/conversation_empty" + android:textAppearance="?attr/textAppearanceBodyLarge" + android:textColor="?attr/colorOnSurfaceVariant" /> + + - + - + - + diff --git a/android/app/src/main/res/layout/fragment_decodes.xml b/android/app/src/main/res/layout/fragment_decodes.xml index f947eb3c7..1ed2a5fc1 100644 --- a/android/app/src/main/res/layout/fragment_decodes.xml +++ b/android/app/src/main/res/layout/fragment_decodes.xml @@ -1,59 +1,103 @@ - - - - - - - - - - - - - - - + android:layout_height="match_parent" + android:layout_margin="8dp" + app:cardElevation="2dp"> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/res/layout/fragment_decodes_bare.xml b/android/app/src/main/res/layout/fragment_decodes_bare.xml new file mode 100644 index 000000000..438694c21 --- /dev/null +++ b/android/app/src/main/res/layout/fragment_decodes_bare.xml @@ -0,0 +1,30 @@ + + + + + + + + + diff --git a/android/app/src/main/res/layout/fragment_everything.xml b/android/app/src/main/res/layout/fragment_everything.xml new file mode 100644 index 000000000..b5efcd75d --- /dev/null +++ b/android/app/src/main/res/layout/fragment_everything.xml @@ -0,0 +1,57 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/res/layout/fragment_mailbox.xml b/android/app/src/main/res/layout/fragment_mailbox.xml new file mode 100644 index 000000000..f3ce349cf --- /dev/null +++ b/android/app/src/main/res/layout/fragment_mailbox.xml @@ -0,0 +1,69 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/res/layout/fragment_messages.xml b/android/app/src/main/res/layout/fragment_messages.xml index 7c0f80568..430e8bd74 100644 --- a/android/app/src/main/res/layout/fragment_messages.xml +++ b/android/app/src/main/res/layout/fragment_messages.xml @@ -6,52 +6,195 @@ android:layout_height="match_parent" android:background="?attr/colorSurface"> - - - - - - - + + + + android:gravity="center_vertical" + android:orientation="horizontal" + android:paddingStart="16dp" + android:paddingEnd="4dp"> + + + + + + + + + - + + + + + + + + android:background="?attr/selectableItemBackground" + android:gravity="center_vertical" + android:minHeight="72dp" + android:orientation="horizontal" + android:paddingHorizontal="16dp" + android:paddingVertical="12dp"> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - diff --git a/android/app/src/main/res/layout/fragment_monitor.xml b/android/app/src/main/res/layout/fragment_monitor.xml index 6e9492b9b..78659556d 100644 --- a/android/app/src/main/res/layout/fragment_monitor.xml +++ b/android/app/src/main/res/layout/fragment_monitor.xml @@ -1,240 +1,83 @@ - + android:layout_height="match_parent" + android:orientation="vertical"> + android:layout_weight="1.1" + android:background="@color/waterfall_background" /> - + + + + + android:visibility="gone" + app:cardCornerRadius="12dp" + app:cardElevation="3dp" + tools:visibility="visible"> + android:gravity="center_vertical" + android:minHeight="56dp" + android:orientation="horizontal" + android:paddingStart="16dp" + android:paddingEnd="8dp" + android:paddingTop="8dp" + android:paddingBottom="8dp"> - + + + android:text="@string/monitor_timing_dismiss" /> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -