A pass at the app's structure, for discussion - #82
Conversation
…p-land variant of the main activity layout puts a NavigationRailView on the left edge, so wide screens spend horizontal space on navigation instead of a full-width bottom bar. Portrait and phones keep the bottom bar, which now always shows its labels via labelVisibilityMode. The activity and fragments reference the nav view as NavigationBarView, the superclass both widgets share, so the same code drives either one.
…ded in the Monitor screen through a FragmentContainerView, so its reply, copy, time-sync, and clear behavior carries over unchanged. In tablet landscape the waterfall keeps the full width, with the status card and start button in a left column and the decode list on the right. Portrait stacks waterfall, status, decodes, and the start button. The status card moved into a shared monitor_status_card layout that both variants include. The Decodes entry is gone from the navigation menu and graph, leaving four destinations.
…howed what the decoder heard, and the engine never hears its own transmissions. The service now broadcasts ACTION_TX_SENT with the joined wire text and TX offset when the engine accepts a transmission, and the activity feeds it into the decode list as an outgoing entry. Outgoing rows show a TX tag in amber in place of the SNR. Auto-replies transmit through the same path, so they appear too. Outgoing entries persist across restarts like other decodes.
…ts a header strip like the Transmit tab's queue card, a row of stat tiles for SNR, power, and TX offset, and Material exposed dropdowns for the audio device and radio frequency in place of plain spinners. The dropdown item-click listener only fires on user taps, which removes the touch, focus, and reentrancy guards the spinners needed. The decode pane became a card with the same header strip and a small clear icon button instead of a toolbar and a floating action button.
… bottom, received messages sit left in gray bubbles, and own transmissions sit right in blue with a TX tag, using the same bubble drawables and colors as the conversation view. Each bubble carries a metadata line with the time, SNR behind a color-coded dot, time delta, and audio offset. The list pins to the bottom edge and scrolls to new messages in the submitList commit callback, since scrolling at submit time raced the background diff and missed the new row. The scroll only follows when the list is already at the bottom, so reading history stays put. Persisted decode files load sorted by timestamp because files from older builds stored newest first.
…ned Everything thread and one thread per DM contact. The Everything thread shows all band activity with a compose bar for CQ and Heartbeat. DM threads send directed messages and show a live sending state on the bubble: a countdown per frame and 1/2 2/2 progress for multi-frame sends. The engine now exposes the TX frame index and count through JNI, and the service broadcasts them when they change. The Transmit tab and its queue UI are gone. Speed moved to a chip in each thread toolbar, and the one-shot commands from issue 77 sit behind a lightning-bolt menu next to the message box. A tap on the Messages nav item inside a thread returns to the index. Also a Material 3 polish pass: official Material Symbols vectors replace the mixed icons, the Start button moved inside the Monitoring card, the thread list uses plain M3 list rows instead of cards, the compose bar is a pill input with a filled send button, and the settings switches use MaterialSwitch instead of the M2 SwitchCompat.
…y decoded station lands in a persistent table with its SNR, offset, grid, and age, sorted starred first and then most recent. A double check mark shows when a station has sent a message directed at our callsign. A row can be starred, given a comment, messaged, or deleted. The contacts table arrives through a real Room migration, so stored messages survive the upgrade.
…records the manual SDK install that works around the sdkmanager zip corruption bug, the Fire HD 10 screen profile for the AVD, and the commands to launch the emulator and run test transmissions through the host speakers and microphone.
…ine. The preview slot existed but only showed the static subtitle. The Messages index now observes the decode feed and shows the newest message as the subline, like a DM row, with the subtitle as the fallback when the list is empty.
…t. An overheard INFO reply stores its text on the sender's contact, and the row shows it above the comment. The field is baked into the version 3 migration, since that schema never shipped. Development installs from earlier builds must clear app data once.
…witching tabs recreates the Monitor fragment, and the saved-state restore sets the dropdown text through the filtering setText. The adapter then keeps only the entry that matches the restored text, and nothing clears the filter, so Stop did not help either. Both exposed dropdowns in the status card now skip view-state saving, because onViewCreated rebuilds their lists and selections from prefs and live state anyway. The audio device dropdown had the same latent bug.
The two bubble drawables carried the old Material icon set paths, which draw a square-cornered bubble on a 24 unit viewport with a hard coded black fill. They now use the current Material Symbols outlines on the 960 unit viewport, tinted from the theme like every other navigation icon, so the selected and unselected states follow the color scheme instead of staying black.
The repository author runs the app on a phone, so layout work needs a phone target next to the Fire HD 10 profile. The doc now carries the Pixel 7 AVD, the keyboard flag that makes adb text input work, and the two port numbers that let both emulators run at once. The Limits section claimed the emulator cannot decode. It can: transmit audio reaches the host speakers and the host microphone feeds the waterfall, so the app decodes its own transmissions through that loop. The note now says so, and records the command that restores microphone passthrough when it drops out.
Calling CQ or sending a heartbeat meant leaving the Monitor screen for a thread, which is a long way around for the two messages sent most often. The header strip on the decode card now carries a CQ text button, a heart plus icon for a heartbeat, and an icon that opens the All activity thread. Both send paths refuse to queue when the engine is stopped or the callsign is unset, and say so in a snackbar. Without that guard the message reached the service, which answered with a raw engine error and flipped the status card to ERROR. The buttons live in the card layout only. The bare decode layout behind All activity keeps its own compose bar for the same commands.
Opening All activity from the decode header pushes it on top of Monitor, which is the start destination. Tapping Monitor from there moved the NavController but left All activity on screen, and only the toolbar back arrow worked. NavigationUI navigates with popUpTo on the start destination and saveState, and that pop never applied its fragment transaction when the target already sat under the current screen. A fragment dump confirmed it: the next tap removed MonitorFragment, not the fragment that was showing. A tap now pops back to the destination whenever it is already on the back stack, and falls through to NavigationUI otherwise. Both the select and the reselect listener share that handler, since a tab is checked only while its own destination shows, so the same tap arrives as either event depending on where the user is. That also folds in the earlier reselect fix for returning to the Messages index from a thread.
The card took a quarter of a phone screen while stopped, and it said the same thing twice: a header band held the word STOPPED and a full width button below it held the word Start. The band existed only to carry that word, which is why the card read as a card inside a card. Sync and Reset sat permanently inside the time drift tile, which is also what left the tile row uneven, since that third column stood taller than the other two. The strip is not a card. It spans the width under the waterfall as its control bar, in two rows near a hundred density pixels tall in place of two hundred and thirty. A colored dot and a state word sit on the left, and a switch replaces the Start button, because the engine is the only thing on this screen that can be switched. Transmit is not a mode, since messages go out per frame, and the rig connects when the engine starts. Both therefore appear as state, not as controls. The second row carries power, transmit offset and time drift on one line, with Sync, Reset and the audio device behind an overflow menu. Frequency became a compact text button that opens a single choice dialog. No exposed dropdown remains on this screen, so the filter on restore bug that emptied the frequency list is gone by construction rather than suppressed. A rig indicator appears beside the state word when rig control is switched on. It is grey before the engine tries, amber while starting, green when the link is alive and red when a running or failed engine has no link, which are the colors the state dot already uses for the same three ideas. The service reports the link through a new rig status broadcast: the connection flags are assigned in too many places to report from each one, so it polls every two seconds while running and broadcasts only on a change. The flag records the last connect result, so a rig unplugged mid session still reads as connected until something tries to use it. The audio device list, the stored choice and the picker moved into a shared AudioDevices object, since the Settings screen offers the same choice next. That also removed the TruSDX coercion in the start path, which forced a non TruSDX selection back to the serial input; the list already holds only the rig's own inputs under that rig type, and an unmatched saved device already falls back to the first available one. The release version moved into a new About category in Settings, the SNR tile is gone, and the strings and the play arrow drawable that nothing references any more went with them.
The device belongs in Settings, since it is a setup choice rather than an operating control, but it also has to stay within reach on Monitor for the moment an interface is plugged in. It now appears in both, and both call the same picker, so one stored key and one code path serve them and the two cannot drift apart. A change made in Settings moves a live capture and shows up on the strip, and the reverse holds too. The preference sets its summary in code and refreshes it on the way in, because the inputs are whatever is attached at the time rather than a fixed list. The snackbar on the strip claimed it was switching the device even with the engine stopped, where there is no capture to move. It now names the device that the next start will use instead.
Transmitting and Error both showed a red dot, in shades close enough that the two states were hard to tell apart at a glance. Red stays on Transmitting, since red for on air is the convention, and the error state now draws a warning glyph in place of the dot. Shape rather than another shade also keeps the strip readable for the red and green color vision deficiencies, which matters here because receiving and transmitting already lean on those two colors. The dot became an ImageView so it can carry either drawable. Its box sets the drawn size, so the dot is twelve density pixels now instead of ten, which is the room the glyph needs to stay legible.
…he parser took the command as a single whitespace token, so MSG TO: arrived as MSG with the destination glued to the payload, and QUERY MSGS arrived as QUERY. The new Js8Commands table mirrors kDirectedCmds from the native varicode source and matches longest name first, so the two-word commands parse whole. Unrecognized text falls back to the old single-token shape, so free-text frames reach callers unchanged. A unit test reads the native table out of varicode.cpp and fails when the Kotlin copy drifts from it.
…ames. The JNI rendered every unpacked data payload through maybe_insert_callsign_prefix, which rewrites text whose first two tokens look like callsigns into TOKEN0: rest. Continuation frames of a buffered command are plain payload text, so a MSG body like KA0XYZ N0CALL QRV arrived with the wrong recipient and a checksum that could never validate. The heuristic now runs only when the frame carries the first-frame bit, which the transmitter sets only on a line's opening frame, never on a continuation.
…buffer that collects a multi-frame MSG expired on a flat 60 seconds, but a Slow-mode transmission spaces its frames 30 seconds apart, so one late decode or one missed frame killed the whole message mid-flight. Each buffer now times out after four frame periods of the submode its command frame arrived in, with the old 60 seconds kept as the floor for the faster submodes.
…e migration fallback. Held messages get their own mailbox_messages table rather than rows in messages, because a held message has an originator and a destination and neither one is us, so putting it there would manufacture phantom conversations. Group destinations record delivery per callsign in a join table, since any station may collect group mail and the message is not consumed. The database now exports its schema, ships a hand-written 3 to 4 migration, and no longer falls back to wiping on a version gap. A silent wipe was always bad, and it is indefensible once the database holds traffic we promised a third party we would forward. An instrumented MigrationTestHelper test covers the upgrade, and the 2 to 4 chain was verified against a populated database on the emulator.
…dcast to the debug receiver forwards synthetic decode text into the same handler chain a real decode takes, so multi-frame commands, malformed frames, and bad checksums can be exercised on one emulator with no audio. The receiver lives in the debug source set and the service ignores the action in release builds, so no exported surface ships.
…: deposit is reassembled across its data frames, checksum-checked, stored in the mailbox, and confirmed with an ACK. The ACK goes out whether or not autoreply is on, because accepting mail and refusing to confirm it is the worst of both. QUERY MSGS answers YES MSG ID or NO from the mailbox, except that a group-addressed query gets YES or silence, since every idle station replying NO to an @allcall sweep would flood the band. QUERY MSG delivers the message attributed to its originator, with NEXT MSG ID appended while more mail waits, and the row is marked delivered only when the transmission finishes, so a failed send stays held. Group messages record delivery per collecting callsign and are never consumed. Serving is gated on the autoreply setting, and accepting deposits at all sits behind a new mailbox preference that defaults off, because holding third-party traffic is a regulatory question in some jurisdictions. Replies are rate-limited per peer per question, keyed on the question so the retrieval a YES invites is not suppressed. A checksum mismatch is logged and tolerated unless a strict flag is set, in case our frame reassembly ever disagrees with the desktop's spacing.
…es tab gets a small header strip whose mailbox icon appears once the mailbox holds anything, badged with the count still waiting for collection. The screen groups messages by destination, with each row showing the originator, age, held or delivered state, and for group mail how many stations have collected it. Tapping a message shows the full text with its relay path and offers deletion, and a menu action clears everything already delivered. Held mail deliberately does not render as conversations, because it is not ours to read into a thread.
…menu gains Check for messages, which asks the station QUERY MSGS, and Send via relay, which deposits a message for the thread's station at a third station's mailbox. The conversation row stays under the person, with the hop recorded in the relayPath column that has been dead since it was added. The replies are now consumed instead of ignored: YES MSG ID fetches the message automatically, NO surfaces as a notice, and collected mail threads under its originator rather than the mailbox that handed it over, following NEXT MSG ID until the mailbox runs dry. The FROM suffix is only parsed when we actually asked that station for mail, because an ordinary message can end in FROM too, and misreading it would file the text under the wrong callsign. An inbound ACK now marks the newest sent message in that conversation acknowledged, which brings the double-check icon to life for the first time.
…n surface. A subscribed group now appears in the Messages list before any traffic arrives, so joining a group is immediately visible. Traffic for groups the operator is not in is no longer dropped: it is stored silently, already read, and lives behind an Other groups entry in the header, so the history is waiting when they join. Joining, from a long-press there or from the thread's menu, adds the group to the subscription list and promotes the thread with notifications. @allcall and @hb stay excluded, since they are broadcast addresses rather than communities and would bury the real groups under every heartbeat on the band, and unsubscribed group history is pruned at thirty days. Group threads also stopped offering the per-station queries in the compose menu, group messages are no longer auto-ACKed, since every subscriber confirming at once would pile the band with ACKs, and tapping a message notification now opens the thread it belongs to, which for a group message is the group rather than a DM with the sender. The well-known groups from the native table are suggested in the new message dialog, with a unit test that fails if the Kotlin list drifts from the native one.
A relay message whose last hop was us got a bare ACK and was then thrown away. Nothing stored it, so a message somebody sent through a relay never appeared in a thread and never raised a notification, and an inbound ACK for our own relayed send was dropped on the floor. The final payload now threads under the station that wrote it, which is the last callsign of the *DE* chain rather than the neighbour that handed it over, with that chain stripped back off the text and the hops it travelled recorded on the row. Mailbox traffic rides the path too. QUERY MSGS, QUERY MSG and MSG TO: are served back down the return path, and a YES we invited is followed automatically, so checking a station for mail works through a relay the same way it works direct. Receiving mail addressed to us no longer needs the relay preference. That setting governs carrying other people's traffic onward, which is the choice an operator actually makes, and it is now checked at the point of forwarding instead of at the door. Desktop drops mail addressed to it in this case, which reads as a defect rather than something worth copying.
Relaying was a one-shot before: a menu item asked for one station and sent one message through it. Everything else in the thread still went direct, including the directed queries and Check for messages, and nothing recorded the route or showed it, so there was no way to tell whether the last message went through anybody. A thread now carries an ordered list of stations. The strip under the toolbar reads Direct, or names the hops, and opens a screen that adds them by tap or by typing and reorders them by drag. Only the first hop is a station we hear ourselves, so the field takes a typed callsign as well as one off the heard list. Three hops is the cap: every hop repeats the whole message, and a three-frame message over two hops is minutes of airtime in Slow mode. Every transmission out of the thread follows the path, composed as HOP>HOP>DEST text the way desktop composes one, which the native packer reads as a directed relay command off the leading token. The path lives in a new conversation_settings table, and the hops that carried a message are kept on the message itself, because the thread's path can change afterwards. Group threads get no strip, since the protocol will not relay to a group. Send via relay stays, renamed to say what it does. It leaves a message at a station's mailbox for later pickup, which is the only way to reach a station that is off the air, and a live path cannot do that.
Tapping a contact opened a three-item dialog, which was a menu standing in for a screen. There was nowhere to see what the app knew about a station, so the grid, the station info, the signal report and whether they hear us were readable only as a cramped line in a list row. Going the other way was worse: a thread named a station in its toolbar and there was no way to get from there to the station. A contact is now a screen. It carries the identity, a Message button, the relay path for that station, how much mail we are holding for them, the fields the radio reported, and editable name and notes. The favourite and delete live in its toolbar. It opens from the contact list, from a thread's toolbar, and from a sender's name on a group message, which was rendered but did nothing before. Edits commit when a field loses focus and again when the screen stops, so there is no Save button to forget. A station can also be given a name, and the name is what the app shows, with the callsign kept underneath it because that is the identity on the air. One helper decides what a station is called so a list row, a toolbar and a notification cannot disagree. Names come from the contacts table and threads from the messages table, and the two are joined in the fragment rather than in SQL, which leaves the conversation query and its known duplicate-row defect alone. Avatars take a colour from the callsign instead of all being the same circle, so a list can be read by shape. Contacts gets a search field over callsign, name, grid, notes and station info, which it needed because the list grows by itself every time the radio hears somebody new.
The C++ side has had docs since the core was extracted, but the Kotlin application on top of it had none: sixty-two files and fourteen thousand lines with nothing describing the screens, the database, the service, or the seam between them. The README pointed at an architecture note that is not in the repo, so it now points here instead. Covers the layer stack down to the JNI wrapper, the broadcast seam between the service and the UI and why it exists, the Room schema and its migrations, the decode and transmit paths, where Kotlin mirrors native protocol tables and the drift guards that keep the copies honest, the screen map, and the test layout. The known weak points are written down rather than left to be rediscovered: the transmit pump only runs while the activity is started, so replies queued while the app is backgrounded wait for it to come back; the queue is in memory and does not survive process death; the conversation query duplicates a row when two messages share a millisecond; and the service has grown to carry audio, rig control, protocol, mailbox and notifications together.
… Every decode is now mined for who-hears-whom evidence: an overheard heartbeat ACK or SNR report proves the sender hears the station it is answering, a HEARING reply lists stations without numbers, a bare ACK proves receipt, and a forwarded relay frame's *DE* trail proves each hop heard the one before it. Each piece becomes a directed row in a new link_observations table (database version 7), stamped with the source type, the dial frequency so links stay scoped to their band, and the observation time. Rows are append-only so the map and the path recommender can weight by recency, and pruned on service start against a new retention setting that defaults to 30 days, with a manual clear in Settings. Links are directed on purpose: A hearing B says nothing about B hearing A, and a relay recommendation will need the reverse direction confirmed separately. The extractor is a pure object with unit tests over each frame shape, and the 6-to-7 migration has a MigrationTest case.
…new Maidenhead util converts a 2, 4, 6, or 8 character locator to the center of its square, converts coordinates back to an 8 character locator, and computes haversine distance and initial bearing between two locators. Coordinates come from square centers, so a 4 character grid carries about fifty kilometers of uncertainty, which also makes haversine plenty against an ellipsoid model. The contact card now shows distance and bearing from the operator's grid to the station's, in both kilometers and miles with a compass point, whenever both grids are known. SettingsFragment's private location-to-grid conversion moved into the same util, so the grid math lives and is tested in one place. Conversion fixtures are checked against independently computed values, and the coordinate round trip is tested across all four sign quadrants.
…'s distance row now shows one unit instead of both, chosen by a new Distance units preference in Settings. Maidenhead.describePath takes the choice as a parameter so the util stays pure.
…orkGraph aggregates link observations into one directed edge per station pair, keeping the most recent numbered SNR rather than an average, because on HF the current state of a path matters more than history blended with propagation that no longer exists. NetworkMapView draws it in the same idiom as the waterfall: a plain custom View with Canvas, no dependencies. A small spring simulation lays the mesh out around our own station, which is pinned at the center and ringed in the theme color. Edge thickness and opacity follow signal strength, and an arrowhead sits at the station that heard. New nodes seed their position from a hash of the callsign so the same stations land in roughly the same places every time. Dragging a node rearranges the graph, empty space pans, pinch zooms, and tapping a station opens its contact card. The screen is reached from a button beside the contacts search field, filters by observation age with 1 hour, 24 hour, and 7 day chips, and reloads itself when new observations land. The aggregation is unit tested, including that an unnumbered ACK newer than an SNR report advances the edge's freshness without erasing its number.
… pinned self node owns the center of the map even with no edges yet, and on the tablet the two sat on top of each other.
…l baseline sat too close to the circle because the 14dp offset was eaten by the text ascent, so the gap is now computed from the ascent plus the ring on our own node.
…ly. The modulator inherits the desktop's assumption that a transmission is requested at a period boundary: asked mid-period, it joins the frame already in progress and transmits only its tail, so tapping Heartbeat keyed the radio at once and sent whatever second or two remained of the current frame. The desktop's TX loop provides that boundary timing, and nothing here did. Every engine transmit call now goes through scheduleAtNextTxBoundary, which defers the call to shortly before the next frame boundary on the engine's drifted clock and passes a txDelaySec large enough to land in the modulator's wait-for-next-period branch, so audio starts cleanly at the boundary plus the submode's fixed on-air offset. The deferred blocks re-check that the engine is still the same instance and not already transmitting, since two sends can now arrive at the same boundary. Verified on the emulator: a heartbeat tapped at 0.4 seconds into a frame fired its engine call 1.5 seconds before the next boundary and produced a full-length transmission instead of a one-second tail.
…. Stopping the engine runs on the main thread and, with rig control connected, waited on a latch for a PTT release posted to the TX handler thread, with no timeout and a blocking retry on the main thread if the release failed. When the radio disconnects, the TX handler can be wedged inside a rig call that never returns, so the posted release never ran and the main thread waited forever, freezing the whole app until Android killed it. The wait is now bounded at three seconds and the main-thread retry is gone. If the release cannot be confirmed in time the link is presumed dead and shutdown continues, which trades a theoretical stuck PTT on an already-disconnected radio for an app that keeps responding. Verified the toggle sequence against an unreachable rigctld host on the emulator with no ANR.
…y froze the app. Bounding the shutdown PTT release was not enough. Testing on the tablet with the radio pulled mid-transmission showed the main thread still blocked for nine seconds: the bounded wait gave up after three, then close() blocked for another six, because every HamlibRigControl method shares one monitor and the wedged rig_set_ptt held it for its full 8.6 second hamlib timeout. The USB bridge has the same shape, where close takes a write lock that a stuck read still holds. So the release, the hamlib close, the serial bridge closes and the network disconnect now all run on the TX handler, which already owns rig I/O and queues them in order behind whatever is stuck. Stopping captures the rig references, clears the fields so a restart cannot pick up a closing link, and returns immediately. The bounded-wait helper it replaces is gone.
…mission. The TX monitor is what broadcasts a terminal state when a transmission ends, and stopping the engine cancels that monitor, so a send in flight never reported anything. The UI kept its Transmitting state indefinitely, and because the queue pump refuses to start a send while it believes one is running, nothing was ever transmitted again for the life of the process. Stopping now reports a failed transmission when a session was in flight, which clears the state and frees the queue. Verified on the emulator: stopping during transmission returns the status strip to Off, and the next heartbeat after a restart reaches the engine instead of being dropped by the pump.
…t of the file. The reasoning belongs in the commit messages, which already carry it.
…d the modulator. The existing js8core-lib tests cover lifecycle, audio submission and TX timing, but none of them proved the engine can actually recover a message, so a decoding regression would have passed the whole suite. Two new tests close that. JS8EngineReferenceDecodeTest feeds the desktop project's own recordings from media/tests straight into the decoder, which takes the microphone and the radio out of the picture entirely: it aligns the ring by shifting the engine clock to the top of a minute, submits the file, and reports what came back. It is a smoke test rather than desktop parity, because the engine decodes a 13.6 second window at a fixed depth where the desktop CLI reads the whole file, so the counts land a little under the numbers in the file names. It also logs each decode's DT, which turned out to be the ground truth for judging whether a timing change actually helped. JS8EngineLoopbackTest goes the other way. It transmits, captures the waveform off the TX tap and decodes it, with no speaker or microphone anywhere in the path, so a failure is the transmitted audio itself. Getting it to pass exposed something worth writing down: the tap sits after the output resampler, so it runs at whatever rate the audio device negotiated rather than the engine's 12000, which is 11520 on the emulator. Feeding those samples to a 12000 Hz decoder stretches everything by four percent and moves a 1500 Hz signal to 1562.5, which decodes as nothing at all. The test now resamples using the rate the tap reports.
…sks before correcting it. Manual drift entry only helps someone who already knows how far off they are, which is a number a user has no way to measure and, judging by my own tablet, one that even years of JS8 experience does not help you guess. The decoder searches about plus or minus two and a half seconds around where it expects a frame, so anything beyond that decodes nothing at all while the waterfall keeps showing signals. Nothing in the app connected those two facts. The engine now notices. Decode cycles for the longest-period enabled submode report whether they found anything, and eight consecutive empty ones arm a search: two minutes of silence on Normal. Any decode resets the counter. While armed, each cycle enqueues one extra snapshot of the same ring with the decode window shifted a quarter period earlier, three of them, which together with the ordinary window covers a whole period with overlap. This needed no decoder change at all, because schedule_decodes already snapshots the ring plus a window and hands it to the decode thread, so a trial is just one more snapshot with a different kpos. Shifts go backwards on purpose. A forward shift reads past the ring's write pointer into the previous minute's audio, which is why the first version swept every offset and found nothing. A step earlier is the same phase modulo the period and it is real data. A trial that decodes runs the same compute_drift_estimate the existing sync uses and reports the total, but its decode is not published as traffic, or accepting the offset would show the same message twice when the ordinary window decodes it again. Giving up is decided when the last trial returns rather than when it is queued, since decoding is on another thread and the earlier version announced failure a fraction of a second before the answer arrived. The arming test is a decode drought rather than "candidates found but nothing decoded", which would be sharper. The decoder can report sync candidates, but only with syncStats set, and that emits an event per candidate. A drought also arms on a genuinely dead band, and that costs nothing visible: the user only ever sees something when a shifted window actually decodes a message, so a false arm burns three extra decodes and gives up in silence, then backs off for about twelve minutes. Nothing is applied automatically. A single mistimed station is enough to drag sync-from-decode onto its own clock, and that is not hypothetical: one of the eight stations in the project's own A_2_9.wav sits at DT plus 1.58 seconds while the rest are inside a third of a second. So the engine proposes and the user accepts. On Monitor that is a card with Dismiss and Fix it. Anywhere else it is a snackbar whose action takes you to Monitor rather than correcting the clock out of context, plus a badge on the Monitor tab for as long as a suggestion stands. The choice between the two is re-evaluated on every navigation as well as on every suggestion, because leaving Monitor is what makes the snackbar right and returning is what retires it, and neither is a change to the suggestion itself. While the search runs the status strip keeps showing the current offset and adds a countdown built from the frame period the engine reports, so Turbo counts down from eighteen seconds and Slow from ninety with no extra code. Manual entry landed first and stays, since a search only helps where there is traffic to find. Monitor overflow gains "Adjust time drift", the drift readout in the status strip opens the same dialog because it was already showing the number, and the dialog refuses anything outside plus or minus thirty seconds rather than accepting a value that cannot work: the RX ring aligns to the UTC minute, so a larger offset simply wraps onto a smaller one. Debug builds can drive all of it from an intent, through tools/show-timing-banner.sh, because waiting out a two minute drought and hoping a shifted window lands a decode is no way to look at a layout. ARCHITECTURE.md carries the full description under Clock alignment.
|
All in all I'm liking the direction you're going. The revamp on the messages is something I've been thinking about doing as well, just never got around to it.... I like the IDEA behind the network map, but that UI seems a little chaotic. I'm not certain how useful it'd be in that format. Something to noodle on. Have you tried that layout on a phone? Looks fantastic on the tablet, but I'd be curious on a device with less space. |
|
@punk-kaos only emulators...no android phones around at the moment. Looks great on emulator, no issues. The network map thing is absolute chaos. I started to collect the data so that I could write an algorithm to help suggest paths to an intended recipient. Then a hair-brained middle of the night fever dream churned that terrible thing out. I regret it now 🤣 |
Emulators work too... If I get a moment I might toss it on my phone and see. I can ALMOST see where that map would work, maybe. Its a very COOL idea. I'd have probably done the same thing in your place. Maybe we can figure out a way to make it functional in some way. If not its still a neat idea. |
|
That would be awesome if you would try it out. There may be some bugs, obviously. I've been running it on my tablet and it's working pretty well. I've noticed a few oddities here and there. Here's what I am thinking broad strokes for order of operations. Each one of these would be multiple PRs, I think, to keep them manageable.
Let me know if you have different opinions, otherwise PRs will start coming your way for anything that doesn't require sequencing! |
|
That seems pretty solid. I think I'm going to drop a 1.0 release here shortly too, as I've added two highly requested QRP radios in the last few days. Then we can start on rolling things into the next release for a big refactor. |
|
That's awesome. Sounds good. Here are the things I am looking at for bug fixes. LMK if you want issues for any of these, otherwise I'll just investigate and open PRs as needed.
And maybe one feature/tests. While I was doing all of this I found it helpful to test the engine using a reference recordings |
I usually run desktop JS8 on my laptop and run the phone next to it and just let both output out the speakers. No radio needed for live 'over the air' tests! |
That's what I was doing, and then I got tired of sitting there doing it manually and decided to automate :D |

This is the branch behind the overview I posted over on #77, plus a few things I ran into along the way.
This is not actually a pull request. By no means am I suggesting or asking this code to be merged. I'm not attached to this code. What I care about are the ideas represented here. I want to use this Proof of Concept to find out which of these ideas we actually want in the app, and then talk about how to land them.
The ideas
What I'm hoping for
Particularly looking for feedback on the messaging model and contacts, since those two change how the app feels more than anything else here(IMO).
My plan would be to cherry pick commits, iterate on the code, then open PRs for the parts we agree on. I had a lot of fun building it, no hard feelings if this just isn't a direction we want to go.
Demo Video
js8-demo.mp4
Screenshots