Skip to content

Modernize the app and fix countless connection stability issues#229

Open
FlorentRevest wants to merge 22 commits into
masterfrom
modernize
Open

Modernize the app and fix countless connection stability issues#229
FlorentRevest wants to merge 22 commits into
masterfrom
modernize

Conversation

@FlorentRevest

Copy link
Copy Markdown
Member

No description provided.

The MTU was pinned to 256 with a comment claiming a 244-byte payload (it
is actually MTU - 3 = 253). That arbitrary value needlessly fragments
every transfer. Now that the previous commit clamps each GATT write to the
512-byte attribute limit regardless of the negotiated MTU, it is safe to
request the spec maximum (517): the connection negotiates the largest MTU
both ends support, transfers use fewer/larger packets, and CAPPED_SPLITTER
still guarantees no write exceeds 512 bytes. Peers that cannot do 517
negotiate down automatically.
createBond() was called on every connect attempt, even when the device
was already bonded, immediately before connect(). This races the bonding
state machine against GATT connection setup and is a well-known cause of
error 133 / failed service discovery (the "forget and re-pair" symptom).
Now bonding is only initiated when the device is BOND_NONE.

Unexpected disconnects (link loss, timeout, peer-terminated) previously
left the watch disconnected with no recovery until the app was
restarted. A managed reconnect is now scheduled on such disconnects,
gated by an explicit user-initiated-disconnect flag and the current
connection state so it never fights a deliberate disconnect nor
double-connects while the stack is already (re)connecting. mState is
made volatile as it is touched from both BLE callback threads and the
main handler.
Services are driven by broadcasts, content observers, alarms and network
callbacks that fire independently of the BLE link. SynchronizationService
.send() now drops writes unless the state is CONNECTED, and
AsteroidBleManager.send() is null-safe instead of calling
Objects.requireNonNull() on a characteristic that does not exist when
disconnected. Together these remove a whole family of NullPointerException
crashes (e.g. a notification arriving while the watch is briefly out of
range).
A flaky reconnect can return a partial GATT table. The discovery code
dereferenced gatt.getService()/getCharacteristic() without null checks,
throwing a NullPointerException inside isRequiredServiceSupported() that
aborted the whole connection and trapped reconnects in a failing retry
loop. Missing services and characteristics are now skipped. Also removed
the meaningless addCharacteristic() calls on already-discovered services.
onDestroy() called disconnect() without enqueue(), so the request never
ran and the GATT connection was leaked on service teardown; it is now
enqueued. updateNotification() now always calls startForeground() instead
of only when a device is set, so a service launched via
startForegroundService() cannot be killed for failing to go foreground in
time. AutostartService starts the service with startForegroundService()
on Android 8+, where starting a plain background service from the boot
broadcast throws IllegalStateException and the watch silently never
reconnects after a reboot.
On a "refresh" request before the listener was connected, NLService spun
in a while(!listenerConnected) loop on the main thread, freezing the UI
and guaranteeing an ANR if the listener never connected. The refresh is
now deferred via a flag and fired from onListenerConnected(); when
already connected it posts the push after a short settle delay. Also
guards against a null broadcast command and wraps getActiveNotifications
in a try/catch.
The MEDIA_COMMANDS_CHAR callback indexed data[0]/data[1] without
verifying the payload length, crashing with ArrayIndexOutOfBoundsException
on a short/empty packet from the watch. onMetadataChanged and the volume
content observer dereferenced getPlaybackInfo() (which can be null) and
divided by getMaxVolume() (which can be 0). All of these are now guarded,
and the content observer uses the main looper explicitly instead of a
bare new Handler().
The download path advanced progress by the full chunk length even when the
bounds-checked arraycopy was skipped, so a desynced or oversized stream
could push progress past size; the size == progress completion check then
never fired, leaving the per-second progress executor running forever and
the buffer copy potentially out of bounds. A data chunk arriving with no
preceding header dereferenced a null buffer. The announced size from the
header was used unchecked for new byte[size] (NegativeArraySize / OOM).

Now the header size is validated against a sane ceiling, chunks are
clamped to the remaining buffer, completion triggers on progress >= size,
and the executor is shut down on completion and on unsync (disconnect
mid-download).
onReceive() registered a fresh PhoneStateListener via telephony.listen()
on each ACTION_PHONE_STATE_CHANGED broadcast and never unregistered it.
Since that broadcast fires multiple times per call and the receiver
instance is discarded after onReceive() returns, listeners accumulated,
leaking and sending duplicate ring notifications to the watch. The call
state is now read directly from the broadcast extras (EXTRA_STATE /
EXTRA_INCOMING_NUMBER), so no listener is registered at all. Also wraps
the contact cursor in try/finally and guards a null number.
If silence-on-connect was enabled and a session was killed before
unsync() could restore the ringer, the next sync() saved RINGER_MODE_SILENT
as the user's "original" mode, leaving the phone permanently muted. sync()
now falls back to NORMAL when it would otherwise record SILENT as the
original.
GPSTracker registers a location listener via requestSingleUpdate() but it
was only stopped when weather sync was toggled off, not on disconnect, so
a pending update outlived the connection. unsync() now stops and clears
the GPS tracker.
The app uses the Nordic BLE library (no.nordicsemi.android:ble); SweetBlue
is not referenced by any source file, nor by app/build.gradle.kts
sourceSets, nor included as a module in settings.gradle.kts (only :app is).
The vendored tree was dead weight, so drop it.
com.github.MagneFire:EasyWeather:1.3 is no longer resolvable: JitPack has
evicted the artifacts and can no longer rebuild them (every version reports
an "Error" build status), so the build failed at checkDebugAarMetadata with
"Could not find com.github.MagneFire:EasyWeather:1.3".

Vendor the library's 18 source files under src/main/lib/easyweather and add
its runtime dependencies (Retrofit, converter-gson, OkHttp logging
interceptor) directly from Maven Central, mirroring how the project already
vendors its other libs. The single android.support.annotation.NonNull import
is migrated to androidx. Resources/manifest are not vendored (unused, and the
library's app_name string would collide). The now-unused JitPack repository is
removed so the build no longer depends on it at all.

EasyWeather is Apache-2.0 (Copyright 2016 Vatsal Bajpai); see the vendored
README for attribution.
- Android Gradle Plugin 7.4.2 -> 8.5.2, Gradle 7.5 -> 8.7. Builds now run
  on JDK 17-21 (the old AGP failed jlink's JdkImageTransform on JDK 21).
- Move to the plugins {} DSL and centralize repositories in
  settings.gradle.kts via dependencyResolutionManagement
  (FAIL_ON_PROJECT_REPOS), replacing the legacy buildscript/allprojects
  blocks. Drop the unused Kotlin Gradle plugin classpath.
- compileSdk 33 -> 34; drop the hard-pinned buildToolsVersion (AGP selects
  it). targetSdk stays 34-pending and is bumped separately with its
  required runtime adaptations.
- gradle.properties: drop android.enableJetifier (no support-library
  dependencies remain after EasyWeather was migrated to AndroidX) and set
  android.nonTransitiveRClass=true (AGP 8 default).
- Bump low-risk dependencies: appcompat 1.7.0, material 1.12.0, gson
  2.11.0, retrofit 2.11.0, osmdroid 6.1.20. The Nordic BLE libraries are
  intentionally kept pinned so the tuned connection lifecycle is undisturbed.
- Declare android:foregroundServiceType="connectedDevice" on
  SynchronizationService and add the FOREGROUND_SERVICE_CONNECTED_DEVICE
  permission, as required for foreground services on Android 14.
- Pass an explicit export flag to every context-registered receiver via
  ContextCompat.registerReceiver(..., RECEIVER_NOT_EXPORTED). Android 14
  requires this for apps targeting 34, otherwise registerReceiver throws at
  runtime. All five receivers (notification listener bridge, notification,
  time, weather, screenshot) only handle app-internal or system-protected
  broadcasts, so NOT_EXPORTED is correct.
Functional fixes (these silently misbehaved on modern Android):
- BluetoothAdapter.enable() has been a no-op since Android 13, so the app
  could no longer turn Bluetooth on; it now launches ACTION_REQUEST_ENABLE
  to ask the user.
- ActivityCompat.requestPermissions(getParent(), ...) passed getParent(),
  which is null for a normal activity, so those BLUETOOTH_CONNECT permission
  requests no-op'd / could NPE. Pass the activity itself.
- Remove the bogus android.permission.ACCESS_LOCATION manifest entry (not a
  real permission; the real ACCESS_FINE/COARSE_LOCATION are already declared).

Deprecation replacements:
- BluetoothAdapter.getDefaultAdapter() -> BluetoothManager.getAdapter().
- Override onBackPressed() -> OnBackPressedDispatcher + OnBackPressedCallback
  (predictive-back ready); the toolbar home button routes through the
  dispatcher too.
- Replace the finish()/overridePendingTransition()/startActivity() theme
  restart with recreate(), which keeps the task and back stack intact.
Replace the deprecated BluetoothAdapter.getDefaultAdapter() calls with
BluetoothManager.getAdapter() via a small helper, and null-check the
adapter before use.
Nothing in the app references legacy-support-v4: the androidx.core /
androidx.fragment classes used come transitively from appcompat and
material, and the vendored material-intro-screen's ViewPager resolves
through material. Both debug and release (incl. lintVitalRelease) build
without it.
Turn on org.gradle.caching, org.gradle.parallel and
org.gradle.configuration-cache. All three were verified against this
project on both the debug and release paths (configuration cache stores
and reuses with no reported problems), giving faster incremental and
clean builds.
Move all plugin and library versions into gradle/libs.versions.toml and
reference them via the libs.* accessors in the build scripts. This is the
modern, tooling-friendly way to manage versions (and plays nicely with
automated dependency updates). No version changes; build output is
unchanged.
Targeting SDK 34 (commit "Target SDK 34 with Android 14 runtime
adaptations") registered the context receivers with RECEIVER_NOT_EXPORTED,
but the matching broadcasts are sent as implicit intents (action only, no
package). On Android 14 a fully implicit broadcast is not delivered to a
NOT_EXPORTED receiver, which silently broke every receiver-driven feature:
screenshot requests, the "find my watch" buzz, notification forwarding and
the call-state notifications — while internal sends (media, weather, time)
kept working. Set the package on every internal broadcast (UI cards,
NLService, PhoneStateReceiver, the notification-refresh trigger and the
time/weather alarm intents) so they are delivered to the app's own private
receivers without exporting them.
Flip PREFS_SYNC_WEATHER_DEFAULT to true so weather tracks the device
location out of the box (the location-picker already exposes the toggle to
turn it off). Also seed the GPS cold start from the last known location so
the first sync shows weather immediately instead of waiting up to two
minutes for the single-update fix. Degrades gracefully when no location or
permission is available.

@MagneFire MagneFire left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Only had a quick look through the changes. Looks good, but have not tested any of this.

// safe because CAPPED_SPLITTER above clamps every write to the 512-byte
// attribute limit no matter what MTU the system negotiates; the peer always
// falls back to a smaller MTU if it can't support the maximum.
private static final int GATT_MAX_MTU = 517;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This probably needs to be tested on various phones as the Bluetooth stack is known to be buggy for different vendors. Google Pixel seems to be one of the few to have a proper Bluetooth stack. Looking at the documentation, pre-Android 14 probably need to be checked (https://developer.android.com/about/versions/14/behavior-changes-all#mtu-set-to-517).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants