updateNotification was called from onReport but read lastState, which onState only sets
afterwards - so the persistent notification was rebuilt from the previous report's
outcome. It now derives the state from the report in hand.
This matters most where it is least visible: an alarm-driven report at 03:00 posts a
Toast nobody sees, leaving the notification as the only surface, and that surface was
showing a stale verdict.
An auditor ran the plan's own release gate against live APRS-IS servers. It failed at
the login step, on every server tried:
sent: user N0CALL pass -1 vers Look4Sat-4.5.4
got: # Invalid login: software name and version are not separated by a space
Reproduced on euro.aprs2.net and noam.aprs2.net, aprsc 2.1.21. `vers` takes TWO tokens,
a software name and a version. An earlier commit read the rule "softwarename must not
contain a space" as "the field must be one token" and hyphenated the space between them
- and the unit test asserted that as correct, so the mistake was frozen in place.
Worse than the malformed line was what happened next. `# Invalid login:` is a comment
but not a logresp, so parse skipped it as keepalive chatter; the login then timed out
into Unknown, which is deliberately treated as "may be working"; so `ok = sent &&
!refused` was true and the operator was shown "APRS: report sent OK" for a login the
server had refused. That is v4.6.0's defining defect - every send reported successful
regardless of outcome - still live on the exact path every operator takes. The rebuild
narrowed it rather than closing it.
Both halves are fixed: the name and version stay separate tokens with whitespace
collapsed within each, and a refusal comment is classified as a refusal before the
logresp test. A socket test now replays the server's actual bytes.
Three smaller things from the same review:
The foreground service type goes back to dataSync. The previous commit chose location
to escape dataSync's six-hour cap, but a location-typed service is refused outright
unless a location runtime permission has already been granted, and the settings card
requests only notifications - so it would have failed silently for anyone who declined
location access. The cap that prompted the switch applies only when targetSdk is 35 or
higher, which this project does not declare. A test now reads the manifest and the
service source and fails if they disagree, which is the only way this class of defect
is visible from a JVM test.
The version string in the login was 4.5.4 while the app was 4.6.0. Now split into name
and version and corrected, though it is still hardcoded - core:data has no BuildConfig,
so passing it in properly is a separate change.
The passcode hint said "empty = auto-computed from callsign" in all five locales. The
app stopped doing that two commits ago; it now connects receive-only, and the hint says
so. It was the first thing an operator read next to the field, promising the behaviour
that was deliberately removed.
Not fixed, and known: the notification body is rebuilt from the previous cycle's state
so it can show a stale verdict, a deliberate receive-only choice is still styled as an
error, and no last-success timestamp exists - so an operator still cannot establish
whether their station has ever reached the network.
The previous commit changed the manifest's foregroundServiceType to location and left
startForeground passing FOREGROUND_SERVICE_TYPE_DATA_SYNC. AOSP requires the passed
type to be a subset of the declared one - location is 0x08, dataSync is 0x01 - and
throws IllegalArgumentException otherwise, a check that has been there since API 29.
That throw landed in the surrounding catch, which calls stopSelf().
So APRS started, died, and said nothing. No notification, no beacon, no Toast, no
last-report row, and the settings switch stayed on because the config had already been
saved. This is worse than the defect the rewrite was written to fix: reporting success
for packets that never left at least sometimes worked, whereas this never ran at all,
on essentially every device in use, with no visible symptom. Two auditors found it
independently by reading the constants against AOSP's own check.
Two more findings from the same review.
Receive-only was reported as a wrong passcode. Both a deliberate -1 and a mismatched
entry log in with -1, and the server answers "unverified" to each, so the operator who
chose receive-only - the one way to test a setup without putting anything on the network
- was told to go and fix the passcode they had set on purpose. The report now carries
whether receive-only was asked for, and says so instead.
The card could show "failed - sent". The detail string was the write's own verdict, and
a write that succeeds on a refused login is exactly the case where those two disagree.
A failure now reports what actually failed.
Also: the packet is built before connecting. The reporter used to open a session and log
in only to discover it had nothing to send, which for an operator with no station
position set meant a pointless login every five minutes.
Still outstanding, and the reason this is not enough on its own: nothing tests the
service, so neither this defect nor the missing line terminator in 7ac54f0a could have
been caught by the suite. Both were found by audit. A location-typed foreground service
on API 34+ may also require a granted location permission before startForeground, which
the settings card does not request - that needs checking on hardware.
The position line was one string template, and it broke four rules at once.
No path. The specification says a client-originated packet carries TCPIP* in the
path, "nothing more or less", and there was none - `CALL>APRS:=...` went out bare.
No position meant 0,0. When the station QTH was unset and no GPS fix was available,
`lat ?: 0.0` put the operator at 0 degrees north, 0 degrees east - a point in the
Gulf of Guinea - on the global network, under their own callsign. There is no honest
default for "nowhere", so AprsBeacon refuses instead and the reporter says why. A
genuine 0,0 fix is still legal and still sent; the refusal is about absence.
No comment sanitising. A line break typed into the status field ended the packet and
started a second one from the remaining text, which an operator could trigger by
pressing return. Measured: the old builder emitted two lines from one call, the second
impersonating whatever callsign the text contained. Only printable ASCII survives now.
No length cap. A 600-character status produced a 636-byte line against a 512-byte
limit including CRLF. The comment is trimmed to whatever room is left after the
header and the coordinates, bounded also by the format's own 43-character limit.
Symbol handling was whatever character the operator typed first, including one that
breaks the fixed-width parse. It now accepts only what the specification allows -
the two table selectors and overlay characters - and falls back to the primary table.
aprs.fi names symbol misconfiguration as the most common reason a station never
appears on the map, so this is not cosmetic.
Separately, beaconing stopped whenever the screen locked. The interval was a coroutine
delay inside the reporter, and Doze suspends network access and ignores wake locks even
for a foreground service: the timer fired on schedule and then could not reach the
network, while the notification went on claiming the service was running. The service
now books each beacon with setExactAndAllowWhileIdle, which is the only scheduling that
survives Doze, and reschedules after each tick so a changed interval applies at once.
If the operator has revoked exact alarms it falls back to an inexact one, which beacons
late rather than not at all.
The foreground service type changes from dataSync to location. dataSync is capped at
six hours in any 24-hour window on recent Android and then stopped by the system, which
would silently end a beacon meant to run all day; the service reads the station position
and falls back to GPS, so location describes what it actually does.
The interval floor becomes five minutes rather than one. This station is fixed or
walking, and APRS-IS etiquette is to beacon no more often than the position changes.
The packet builder moves to core:domain as pure logic, so all of this is testable
without a socket - including that a comma-decimal locale cannot corrupt the coordinates,
which nothing covered before.
AprsReporter derived a passcode from the callsign whenever the operator's entry was
unusable:
passcode = cfg.passcode.toIntOrNull()?.takeIf { it >= 0 } ?: AprsPacket.passcode(cfg.callsign)
Measured against the shipped algorithm for BG7NTA, whose passcode is 21162, four
inputs produced a transmit passcode the operator never obtained: blank, whitespace,
non-numeric, and an explicit -1. That last one is the documented receive-only value,
so `takeIf { it >= 0 }` also made receive-only unreachable - and a receive-only login
is the one way to confirm a setup works without putting anything on the network,
which is exactly how this feature was supposed to be validated before release.
This is a policy question more than a bug. APRS-IS states that supplying the correct
passcode to a user is the software author's responsibility, and the passcode functions
as a licence check for transmitting. APRSdroid carries the same algorithm in the same
source file and deliberately does not use it to fill a blank, validating the operator's
entry instead. AprsPasscode follows that: it classifies an entry as Transmit,
ReceiveOnly, Mismatch or NotANumber, and anything not usable logs in as -1. The
connection still works and the operator is told separately that reports are not being
forwarded, but no packet goes out under a code the app made up.
AprsPacket.passcode stays, because validating an entry means recomputing the expected
value. Nothing substitutes it for a missing one.
Separately, and worse than the line above: the report notices never appeared at all.
onReport is invoked from AprsReporter's Dispatchers.IO scope, where constructing a
Toast throws because the thread has no Looper - and the surrounding runCatching
swallowed it. So the whole reporting path, including the unverified-login warning
added in the previous commit, was writing messages nobody could see. They now post to
the main looper. The one in startReporting is left alone: onStartCommand already runs
on the main thread.
Still outstanding for APRS, and not addressed here: the 0N 0E position fallback, the
missing TCPIP* path, the unbounded status field, the coroutine delay that does not fire
in Doze, and the dataSync foreground service type. Also unaddressed is the "Compute
passcode" button in AprsCard, which offers the operator the derived value directly and
so contradicts the policy this commit establishes - it was added by request, so it needs
a decision rather than a quiet removal.
Two defects made every APRS failure invisible. sendPacket ended with
`.getOrElse { Pair(true, "OK") }`, so a read that threw - including on a dead
socket - was reported as a successful send. And the login check threw inside a
runCatching whose result was discarded, so a server that refused to verify the
passcode could not propagate: aprsc keeps such a client connected and its writes
succeed while silently discarding every packet, which the app reported as success.
An audit put it plainly - twelve commits are all fix(aprs), none added a
socket-level test, so "it worked" was never evidence a packet had landed.
sendPacket now separates the cases. A read timeout stays a success, because
APRS-IS does not acknowledge position reports and silence is the normal outcome.
A closed stream or an IOException is a failure. The catch order matters and is
load-bearing: SocketTimeoutException extends IOException, so reversing them would
mark every normal report as failed.
The login handshake follows the spec: read the server's identification line first,
then log in, then read until a verdict arrives. AprsLogin holds that as pure logic
in core:domain with the parsing that decides it, including one trap worth naming -
"unverified" contains "verified", so the negative has to be tested first or every
refusal reads as acceptance. An explicit refusal now fails the report and shows
the operator its own message pointing at the callsign and passcode, in five
locales. A response we could not parse does not, since the packets may well be
landing and blaming the passcode would send them to fix something that works.
Three defects came out of review after that. The verdict is now bounded by a
deadline rather than a five-line budget, because a server that sent six keepalives
before its answer turned an accepted login into Unknown - telling the operator
their passcode was wrong when it had just been accepted. The greeting gets a short
two-second probe instead of the full login window, which cost eight seconds on
every connect to a server that sends none. And a refusal detected in the greeting
now aborts the connection instead of being overwritten by the next read, which had
made that branch and its comment a lie.
The worst of the three was mine: rewriting the write as print + flush dropped the
line terminator entirely. APRS-IS is a line protocol, so the server's reader never
saw a packet, while the send reported success and the read timed out into the
"silence is normal" branch. It broke healthy connections rather than dead ones and
was designed to have no symptom. Both the packet and the login line now end in an
explicit CRLF as the spec requires, rather than println's platform separator.
That defect is why this adds AprsIsClientSocketTest, which runs the client against
a stand-in server and reads the bytes back: it asserts two packets arrive as two
lines, that a login line arrives complete, that keepalive chatter does not bury the
verdict, that a greeting-less server connects promptly, and that a send to a closed
peer reports failure. Nothing in the pure-logic tests could have caught a missing
newline. Note for anyone extending it: closing the ServerSocket leaves an
established connection alive, so the dead-peer test has to close the accepted
socket - assuming otherwise made a correct implementation look broken.
AprsPacket.formatLogin is deleted, its work moved into AprsLogin.line, which also
replaces spaces in the version string because the server splits that field on
whitespace and the shipped value contained one.
Every ACTION_START - and every null intent delivered by START_STICKY - called
startReporting(), which always constructed a new AprsReporter and overwrote the
field without stopping the old one. AprsReporter owns an independent
SupervisorJob + periodic while(isActive) loop, so every overwritten instance
kept reporting forever and could no longer be reached by ACTION_STOP.
Simulation:
five ACTION_START events: 5 running reporters, 4 leaked -> fixed: 1 / 0
START + 3 sticky restarts: 4 running, 3 leaked -> fixed: 1 / 0
mixed real sequence: 4 running, 3 leaked -> fixed: 1 / 0
At the default 10-minute interval, four leaked reporters send 24 duplicate
position packets per hour and open 24 needless connections; this also amplifies
the connect-time socket leak fixed earlier.
startReporting now returns when the current reporter is active. Config changes
remain correct: AprsCard explicitly sends ACTION_STOP before ACTION_START, so
the old reporter is stopped and nulled before the new configuration starts.
:app:compileReleaseKotlin BUILD SUCCESSFUL.
Per upstream author feedback (PR #233) and tablet UX report:
- Bottom bar keeps max 5 primary destinations: Satellites/Passes/
AMSAT/Map/Settings; Radar moves to the More menu (still reachable
from Passes via item click)
- Legacy migration: persisted orders are rewritten in memory
(main menu drops Radar + appends AMSAT; More menu drops AMSAT +
appends Radar) so existing installs get the new layout
- Wide screens / tablets (width breakpoint) now use the side
navigation rail instead of the bottom bar - fixes the wasted
bottom strip ("big chin") in landscape/tablet layouts
- New AMSAT tab icon: MDI satellite-variant (Apache 2.0,
https://pictogrammers.com)
- What's new updated in 5 locales; version 4.5.6 (457)
All Chinese comments (//, /* */, KDoc) across core/app/feature/build-
logic translated to English (550 lines, 73 files after FT8 rollback).
Code logic untouched - comment text only. Verified: all modules
compileDebugKotlin BUILD SUCCESSFUL.
User testing round 3 (4.5.4): no success feedback on upload, aprs.fi
shows nothing, passcode calculator OK, notification present.
- Manual report now works even when service not running: ACTION_REPORT_NOW
starts the service first (Toast "not configured" if missing callsign)
- Upload result feedback guaranteed: Toast always shows (short OK /
long fail+reason), last result persisted (time/ok/detail) and shown
in the settings card "Last report: HH:mm:ss OK/failed - detail"
- Position source: station position from settingsRepo (user decision)
with live GPS last-known as fallback
- sendPacket reads the server confirmation line (short 3s timeout);
server error text (Invalid/error) surfaces in Toast + card
- Strings EN/ZH/TR/IN/ID +4 keys
Verified: compileDebugKotlin all modules BUILD SUCCESSFUL,
check_strings 9 files OK.
User feedback round 2 (4.5.4): no notification shown, report not
sending, no error visibility on crash. Diagnosis: APRS-IS port 14580
reachable (verified with real login test), 24580 SSL refused; login
format OK. Fixes:
- Settings dialog: "Compute passcode" button - fills passcode from
callsign via the ported 0x73E2 algorithm (user can see the result)
- Global crash handler in MainApplication: stack trace appended to
files/crash_log.txt so crashes are diagnosable (user: no crash logs
were available before)
- POST_NOTIFICATIONS runtime permission requested when enabling APRS
(Android 13+ otherwise silently hides the service notification)
- AprsIsClient: read the login response (aprsc "# logresp ...
unverified"/"Invalid") and surface the server message as the error
- AprsForegroundService: Toast on manual report result (OK / failure
with server reason)
- Strings EN/ZH/TR/IN/ID +3 keys
Verified: compileDebugKotlin all modules BUILD SUCCESSFUL,
check_strings 9 files OK.
Root cause: user reported crash on enabling APRS with a callsign set
(empty callsign worked because the service stops early and never
reaches startForeground). Android 14+ requires the service to declare
foregroundServiceType when startForeground passes a type; the service
had none -> process died on toggle.
- Manifest: add android:foregroundServiceType="dataSync"
- AprsCard: use startForegroundService() for start/report actions
(Android 8+ standard for foreground services)
- AprsForegroundService: try-catch around startForeground, fallback
stopSelf instead of killing the process
- AprsReporter: passcode "-1" (APRSdroid "no auth" convention) also
auto-computes from callsign
- Merged upstream 418a05e3 (zh wording fix, no conflicts)
Verified: compileDebugKotlin all modules BUILD SUCCESSFUL,
check_strings 9 files OK.
- prefs_aprs_summary uses %2$d but AprsCard passed port.toString()
(String) -> String.format threw IllegalFormatConversionException
on every Settings screen open (crash). Pass Int now.
- Manifest service name was ".AprsForegroundService" (resolves under
applicationId) but the class lives in com.rtbishop.look4sat.app ->
ClassNotFoundException when toggling. Use full class name.
Verified: :feature:settings:compileDebugKotlin + :app:compileDebugKotlin
BUILD SUCCESSFUL.
New feature/status module: fetches https://amsat.org/status/ and
renders a live status grid in the official site colors:
- Parser (AmSatParser): 47 satellites x 6 days x 12 two-hour slots,
official colors (blue=Active, orange=TLM/Beacon, pink=Not Heard,
deep-orange=Conflicting, gray=none); 598+ report details extracted
from inline JS tooltips (callsign/date/time/grid)
- Three-level viz: color grid -> report count -> tap day cell opens
report list dialog
- Manual refresh with spin animation + last-updated timestamp +
legend row; loading/error states
- New "AMSAT" entry in the More menu (Screen.AmSat), integrated with
page-order / hide-page settings (SettingsScreen screens list,
defaultSubMenuOrder, allNavItems, migration for existing users)
- AmSatRepository via IRemoteSource.getStatusHtml() (UA header);
shared remoteSource promoted to a lazy class property in MainContainer
Radar page Log tab: local entries now grouped by pass session with a
thick divider + satellite label between groups (matches the log page).
What's new updated in EN/ZH/TR/IN/ID. Version bumped to 4.5.3/453.
Not released (user gates all releases).
User-test fixes (3rd overwrite, version stays 4.5.2/452):
1. Log page was invisible everywhere outside the radar tab:
- allNavItems in MainScreen.kt was missing Screen.WavelogLog ->
not in bottom nav, not in More menu
- SettingsScreen UI-order lists were missing the WavelogLog row ->
not in page order settings either
Now the Log page appears in the More menu (default sub menu tail,
migration appends it) AND in UI settings page-order lists.
2. Frequency display: formatFrequency was MHz.kHz.Hz (145.900.000);
now MHz.kHz (145.900) per request. Applies to transceiver panel
and Log page alike (shared formatter).
3. Log tab frequency: now extracts the exact numbers shown in the
transceiver panel (txBaseFrequencyHz for TX, uplinkLow/uplinkHigh
range for linear) - no re-computation, no extra decimals. Upload
uses the same tuned frequency (Hz precision kept internally).
4. Log page (More menu) table: date+time column (MM-dd HH:mm),
row separators (table lines), uploaded checkmark kept.
Verified: check_strings OK (8 files); :app:compileDebugKotlin
BUILD SUCCESSFUL (includes upstream merge 1a552417).
Background: user tested 4.5.2 and reported 4 issues. Same-version
overwrite per user (4.5.2 exists solely for the logbook system).
Fixes:
1. Upload 404 — root cause: user server URL ending in /index.php was
concatenated again (/index.php/index.php/api/v2/...) -> 404. Now:
- normalizeUrl strips trailing /index.php
- every request tries the index.php path first, falls back to the
rewritten path on 404
- 409 conflict (duplicate QSO) counts as success (moves out of queue)
- failure messages include the actual URL + HTTP code for debugging
2. Swipe-to-delete was dead: rowWidth was never measured (0) so the
75% threshold was 0 and the drag was clamped to 0. Now measured via
onSizeChanged + smooth spring/tween snap-back animation.
3. Transponder picker: long card list replaced with an
ExposedDropdownMenuBox dropdown (scrollable menu, pick one).
4. New Log page under the More menu: table view (time / frequency /
satellite / callsign / uploaded checkmark). WavelogQso gains
uploaded flag; uploader marks instead of removing; queue keeps
uploaded entries (500 cap). Old persisted subMenuOrder gets
WavelogLog appended (migration).
Verified: check_strings.py OK (8 files, 452/4.5.2);
:app:compileDebugKotlin BUILD SUCCESSFUL.
Background: 8 bottom-nav items squeeze long English labels on narrow
screens. 4.5.1 introduces the 5+N pattern: 5 main tabs plus a fixed
6th "More" button that pops a second-level menu (spring bounce) with
the remaining pages.
Changes:
- MainScreen: nav split into main (<=5, screenOrder-driven) + more
(subMenuOrder); More button with popup panel + spring animation;
BackHandler closes the menu before navigating back
- MoreMenuPopup: bottom-end card, current page highlighted, scrim
click to dismiss
- UI Settings: page order card now has two zones (main menu, max 5,
Settings locked last with no drag handle / more menu); move buttons
between zones, drag-to-reorder within zones; new subMenuOrder pref
- Defaults: main = Satellites/Passes/Radar/Map/Settings,
more = Mutual/Roaming/CwDecode; old screenOrder migrates by
classifying pages against the default sub menu
- Hard-coded UI strings localized (Tracking/Lat/Lon/Qth/Connect/
Track/Stop/CW permission prompts) into EN/ZH/TR
- NEW Indonesian locale (values-in, 181 strings + cw module strings)
- user rule: every future release must update EN/ZH/TR/IN
- Version 4.5.1 (451)
Verified: check_strings.py OK (8 files, no bare apostrophes);
:app:compileDebugKotlin BUILD SUCCESSFUL locally.
Decode page showed stats but empty waterfall and no decoded text:
the ported i3/d.k() FFT dispatch was broken by a jadx structure
misplacement - the runtime path (k=1 -> a5=0 -> cond_22 single-thread
FFT) was replaced by a hallucinated `throw null` else-branch while the
real cond_22 code sat in a dead else. Verified against smali
(7030-7263): j3.c.q forward FFT + post-processing loop + tail + small-
array branch now live in the a5==0 branch.
Also:
- UiSettingsCard now lists CwDecode (toggle + drag-reorder) between
Roaming and Map
- unknown screenIds in persisted screenOrder fall back to
defaultScreenOrder position (CwDecode lands between Roaming and Map
for existing users instead of trailing after Settings)
Verified: :feature:cw + :feature:settings + :app compileDebugKotlin
BUILD SUCCESSFUL.
Compose integration of the ported CW decoder engine:
- CwDecodeScreen: AndroidView embedding the ported activity_main.xml,
lifecycle delegated to the ported MainActivity controller (onCreate ->
onResume, onDispose -> onPause/onDestroy), RECORD_AUDIO runtime
permission flow (with permanent-denial -> app settings), original
options_menu actions as a top button row (pause/clear/save/record/
settings), double-back-to-exit preserved
- CwSettingsDialog: message_type (general_text/ham_radio_qso),
text_font_size (7-99), and the 9 color keys (bg_color/text_color/...)
reading/writing the same prefs keys as the original app
(getPackageName()+"_preferences"), colors sourced from I2.b tables
- Navigation: Screen.CwDecode ("CwDecode") placed between Roaming and
Map in the default order; defaultScreenOrder updated; ic_cw morse icon;
nav_cw strings (en/zh/tr); app depends on :feature:cw
Verified: :feature:cw:compileDebugKotlin + :app:compileDebugKotlin
BUILD SUCCESSFUL (first pass, no errors).
Users can now change the bottom navigation order (previously fixed):
- New "Page order" section under the Settings toggle in the UI
Settings card: vertical list of all 7 pages, each with a drag
handle (new ic_drag drawable, Material drag_indicator glyph) on
the right.
- Drag a handle up/down: the item follows the finger with live
swap + animateItem() placement animation; on release the order is
persisted (OtherSettings.screenOrder, comma-separated in prefs so
order survives; StringSet would not).
- "Reset order" button restores the default order
(Satellites/Passes/Radar/Mutual/Roaming/Map/Settings).
- MainScreen now sorts navItems by screenOrder (empty = default,
stable sort keeps the canonical order); hiddenScreens filtering
unchanged; Settings entry always visible.
- New strings prefs_ui_order_title / prefs_ui_order_reset in
en/zh/tr; What's-new updated in all three locales.
Version stays 4.4.9 (覆盖 per user). Verified: core:data tests
pass, settings + app compile.
Two changes per user request:
1. Data source toggles default OFF with legacy-URL migration
(continuation of the 4.4.8 fix, now also in the dialog): old
example.com placeholder URLs are replaced by the real defaults and
the custom toggles stay off, so the online update never points at a
dead source after upgrading.
2. New "UI Settings" card between Other Settings and Credits:
one switch per bottom-navigation page (卫星/过境/雷达/匹配/漫游/地图/设置
in fixed order). Turning a page off removes it from the nav bar and
the remaining items close up automatically; order is never
rearranged. The Settings entry is always visible (switch disabled)
so the user can never lose access to settings. Persisted via
OtherSettings.hiddenScreens (StringSet of Screen.screenId; screenId
added to the Screen sealed class - simpleName is unsafe under R8).
Version bump per fork convention: 4.4.8 -> 4.4.9, versionCode 449.
What's-new updated in en/zh/tr.
Verified: core:data + core:domain tests pass, all modules compile.
Addresses three review findings:
1. Coordinates now come straight from settingsRepo.stationPosition in the screen (collectAsStateWithLifecycle) — the exact same StateFlow the Settings page shows. Previously a separate ViewModel re-derived them, and it could lag behind the Settings page (user: '设置页更新了站位但漫游页死活不更新'). With the shared source the two pages can never disagree. RoamingViewModel removed; state derivation moved to RoamingState.fromPosition().
2. Red marker placement ported faithfully from the QTH定位器 app: it is driven by the 3rd character pair of the 8-char locator (the 'ih' in OL42ih45), mapped to a 0..1 fraction (lon a=west..x=east, lat inverted a=south..x=north), then scaled to the actual center-cell size. The grid now uses the reference proportions (columns 21.4/56.2/21.4, rows 31.5/35.9/31.7) and fills the screen, so the marker lands accurately on any device.
3. Workflow now uploads a versioned APK (look4sat-<version>.apk instead of look4sat.apk).
Also: Settings 'Other' card rows got vertical spacing (Arrangement.spacedBy) so the new roaming toggle is not glued to the night-mode row.
Ports the QTH定位器 (com.us1pm.gridsquarelocator) location panel into Look4Sat as a new 'Roaming' page, restyled with the app's own look.
UI (top to bottom):
- Info header: GPS status dot, big 8-char locator, Lat/Lon rows with DMS + 5-decimal display, and a GPS 定位 button
- 3x3 grid panel: the current 4-char Maidenhead square (e.g. OL42) centered, surrounded by its 8 neighbors (OL33..OL51), with a red position marker in the center cell
Logic:
- QthConverter gains qthNeighbors(square) building the 3x3 grid with field/square carry at boundaries (AA00 wraps to RR99, IO91 crosses into J field), and qthToSquare(locator) extracting the 4-char square
- Verified against the decompiled app algorithm and the reference screenshot (OL42 grid matches exactly); 9 unit tests cover normal, boundary and field-wrap cases
Navigation:
- New bottom-nav item 'Roaming' between Match and Settings, with a crosshair icon
- New feature:roaming module (ViewModel + Compose screen) registered in the app
Build config:
- Lowered Gradle JVM heap from -Xmx6g to 768m: the 2GB build server froze on the old value; heavy release builds stay on GitHub Actions
The official app signs com.rtbishop.look4sat with its own certificate. A fork sharing that applicationId cannot be installed over the official build (signature mismatch) and users saw overwrite/install failures.
- Add applicationId version catalog entry; namespace stays com.rtbishop.look4sat so source imports are untouched, applicationId becomes com.rtbishop.look4sat.bg7nta.
- Update PROPERTY_SATELLITE_DATA_OPTIMIZED meta-data to the fork id.
- Document the fork-applicationId requirement in the version catalog.
- MutualViewModel is Activity-scoped, so returning from Radar keeps query results
- TrackSampleData gains time field for live cut-off
- Removed standalone mutual card from radar page
- RadarViewCompose draws optional dashed station-B track + current dot
- RadarScreen builds trackB from mutual data up to current time
- Fix cubic Bezier control points (proper Catmull-Rom to Bezier conversion)
- Refine AOS/LOS more robustly by walking from edge into the pass window
- Still 5s sampling for smooth curves
- Pre-fill station A grid from settingsRepo.stationPosition
- Find ALL mutual passes per satellite (not just first)
- Fix Color.hashCode() -> Color.toArgb() for native canvas paint
- Use 2min gap between pass searches to avoid duplicates
Port satlover.de dual-station pass matching feature:
- New feature/mutual module with mutual pass data model
- MutualViewModel: compute overlapping passes for two stations
- ElevationCurveChart: Canvas-based dual elevation curve with drag
- MutualScreen: input form + results with expandable cards
- Navigation: add Mutual tab to bottom navigation bar
- i18n: add Chinese/English strings for new feature