Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
e8519e5
Request the maximum ATT_MTU now that writes are capped
FlorentRevest Jun 17, 2026
f472904
Fix createBond race and add managed reconnect on link loss
FlorentRevest Jun 15, 2026
e61832b
Guard the BLE send path against disconnected/missing characteristics
FlorentRevest Jun 15, 2026
e47fe5e
Harden GATT service discovery against partial tables
FlorentRevest Jun 15, 2026
a19b301
Make foreground promotion and boot autostart reliable
FlorentRevest Jun 15, 2026
711f7de
Replace notification-listener busy-wait with event-driven refresh
FlorentRevest Jun 15, 2026
9498dc3
Harden MediaService against malformed watch payloads
FlorentRevest Jun 15, 2026
0344336
Make screenshot download robust against desync and disconnect
FlorentRevest Jun 15, 2026
00f6d0f
Stop leaking PhoneStateListener on every call broadcast
FlorentRevest Jun 15, 2026
dd16764
Avoid recording silent as the original ringer mode
FlorentRevest Jun 15, 2026
a4a750e
Release GPS listener when WeatherService unsyncs
FlorentRevest Jun 15, 2026
888a3f7
Remove unused vendored SweetBlue library
FlorentRevest Jun 15, 2026
795242a
Vendor EasyWeather instead of the dead JitPack dependency
FlorentRevest Jun 15, 2026
9843efc
Modernize build system: AGP 8.5.2, Gradle 8.7, compileSdk 34
FlorentRevest Jun 15, 2026
36e0691
Target SDK 34 with Android 14 runtime adaptations
FlorentRevest Jun 15, 2026
f8ba421
Fix Bluetooth enable / permission flow and modernize MainActivity
FlorentRevest Jun 15, 2026
24160db
Use BluetoothManager.getAdapter() in SynchronizationService
FlorentRevest Jun 15, 2026
225be13
Drop unused legacy-support-v4 dependency
FlorentRevest Jun 16, 2026
05d7777
Enable Gradle build cache, parallel and configuration cache
FlorentRevest Jun 16, 2026
32f7f26
Centralize dependencies in a Gradle version catalog
FlorentRevest Jun 16, 2026
a7ad21d
Target internal broadcasts at the app so NOT_EXPORTED receivers get them
FlorentRevest Jun 17, 2026
27e1d9a
Enable GPS weather sync by default
FlorentRevest Jun 17, 2026
4789dc1
update workflow actions; switch from adopt to temurin (see https://bl…
Foxite Jul 27, 2026
f906c57
run assemble and build separately, upload apk even if checks fail
Foxite Jul 27, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 17 additions & 11 deletions .github/workflows/android.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,26 +14,32 @@ jobs:
runs-on: ubuntu-latest

steps:
- uses: actions/checkout@v2
- uses: actions/checkout@v7

- name: set up JDK 17
uses: actions/setup-java@v2
uses: actions/setup-java@v5
with:
java-version: '17'
distribution: 'adopt'
distribution: 'temurin'

- name: Grant execute permission for gradlew
run: chmod +x gradlew
- name: Build with Gradle
run: ./gradlew build

- name: Archive linter results
uses: actions/upload-artifact@v3
with:
name: lint-result.html
path: app/build/reports/lint-results-debug.html
- name: Assemble build artifact
run: ./gradlew assemble

- name: Archive debug.apk
uses: actions/upload-artifact@v3
uses: actions/upload-artifact@v7
with:
name: asteroidossync-debug.apk
path: app/build/outputs/apk/debug/app-debug.apk

- name: Run checks
run: ./gradlew build

- name: Archive linter results
uses: actions/upload-artifact@v7
if: always()
with:
name: lint-result.html
path: app/build/reports/lint-results-debug.html
37 changes: 17 additions & 20 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -1,15 +1,14 @@
plugins {
id("com.android.application")
alias(libs.plugins.android.application)
}

android {
compileSdk = 33
buildToolsVersion = "30.0.3"
compileSdk = 34

defaultConfig {
applicationId = "org.asteroidos.sync"
minSdk = 24
targetSdk = 33
targetSdk = 34
versionCode = 29
versionName = "0.29"
}
Expand All @@ -25,6 +24,7 @@ android {
srcDir("src/main/lib/android-ripple-background/library/src/main/java/")
srcDir("src/main/lib/material-intro-screen/material-intro-screen/src/main/java/")
srcDir("src/main/lib/powerampapi/poweramp_api_lib/src/")
srcDir("src/main/lib/easyweather/src/main/java/")
}
res {
srcDir("src/main/lib/android-ripple-background/library/src/main/res/")
Expand All @@ -45,22 +45,19 @@ android {
namespace = "org.asteroidos.sync"
}

repositories {
mavenCentral()
maven("https://maven.google.com")
maven("https://jitpack.io")
}

dependencies {
implementation(fileTree(mapOf("dir" to "libs", "include" to listOf("*.jar"))))
testImplementation("junit:junit:4.13.2")
implementation("androidx.appcompat:appcompat:1.6.1")
implementation("androidx.legacy:legacy-support-v4:1.0.0")
implementation("androidx.cardview:cardview:1.0.0")
implementation("com.google.android.material:material:1.9.0")
implementation("com.github.MagneFire:EasyWeather:1.3")
implementation("com.google.code.gson:gson:2.10.1")
implementation("org.osmdroid:osmdroid-android:6.1.16")
implementation("no.nordicsemi.android.support.v18:scanner:1.6.0")
implementation("no.nordicsemi.android:ble:2.7.2")
testImplementation(libs.junit)
implementation(libs.androidx.appcompat)
implementation(libs.androidx.cardview)
implementation(libs.material)
// EasyWeather is vendored under src/main/lib/easyweather (see its README);
// these are the runtime dependencies it needs, from Maven Central.
implementation(libs.retrofit)
implementation(libs.retrofit.converter.gson)
implementation(libs.okhttp.logging.interceptor)
implementation(libs.gson)
implementation(libs.osmdroid.android)
implementation(libs.nordic.scanner)
implementation(libs.nordic.ble)
}
3 changes: 2 additions & 1 deletion app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,14 @@
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.ACCESS_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.NEARBY_WIFI_DEVICES" android:usesPermissionFlags="neverForLocation"/>

<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_CONNECTED_DEVICE" />
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS"/>
<uses-permission android:name="android.permission.READ_PHONE_STATE"/>
<uses-permission android:name="android.permission.READ_CALL_LOG"/>
Expand Down Expand Up @@ -87,6 +87,7 @@
android:name=".services.SynchronizationService"
android:enabled="true"
android:exported="false"
android:foregroundServiceType="connectedDevice"
android:label="SynchronizationService" />

<receiver android:name=".services.AutostartService"
Expand Down
66 changes: 44 additions & 22 deletions app/src/main/java/org/asteroidos/sync/MainActivity.java
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
import android.app.AlertDialog;
import android.bluetooth.BluetoothAdapter;
import android.bluetooth.BluetoothDevice;
import android.bluetooth.BluetoothManager;
import android.content.ComponentName;
import android.content.Context;
import android.content.Intent;
Expand All @@ -43,6 +44,7 @@
import android.util.Log;
import android.view.MenuItem;

import androidx.activity.OnBackPressedCallback;
import androidx.activity.result.ActivityResultLauncher;
import androidx.activity.result.contract.ActivityResultContracts;
import androidx.annotation.NonNull;
Expand Down Expand Up @@ -89,6 +91,7 @@ public class MainActivity extends AppCompatActivity implements DeviceListFragmen
public final ParcelUuid asteroidUUID = fromString(AsteroidUUIDS.SERVICE_UUID.toString());
Messenger mSyncServiceMessenger;
ActivityResultLauncher<Intent> mLocationEnableActivityLauncher;
ActivityResultLauncher<Intent> mBtEnableLauncher;
LocationManager mLocationManager;
/* Synchronization service events handling */
private final ServiceConnection mConnection = new ServiceConnection() {
Expand Down Expand Up @@ -116,7 +119,7 @@ public void onScanResult(int callbackType, @NonNull ScanResult result) {
mListFragment.deviceDiscovered(result.getDevice());
if (ActivityCompat.checkSelfPermission(getApplicationContext(), Manifest.permission.BLUETOOTH_CONNECT) != PackageManager.PERMISSION_GRANTED) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
ActivityCompat.requestPermissions(getParent(), new String[]{Manifest.permission.BLUETOOTH_CONNECT}, 225);
ActivityCompat.requestPermissions(MainActivity.this, new String[]{Manifest.permission.BLUETOOTH_CONNECT}, 225);
}
return;
}
Expand All @@ -128,6 +131,7 @@ public void onScanResult(int callbackType, @NonNull ScanResult result) {
private Fragment mPreviousFragment;
private BluetoothLeScannerCompat mScanner;
private SharedPreferences mPrefs;
private OnBackPressedCallback mBackCallback;

@Override
protected void onCreate(Bundle savedInstanceState) {
Expand All @@ -153,6 +157,17 @@ protected void onCreate(Bundle savedInstanceState) {
new ActivityResultContracts.StartActivityForResult(),
result -> btEnableAndScan()
);
mBtEnableLauncher = registerForActivityResult(
new ActivityResultContracts.StartActivityForResult(),
result -> btEnableAndScan()
);
mBackCallback = new OnBackPressedCallback(true) {
@Override
public void handleOnBackPressed() {
handleBack();
}
};
getOnBackPressedDispatcher().addCallback(this, mBackCallback);
btEnable();

/* Start and/or attach to the Synchronization Service */
Expand Down Expand Up @@ -271,29 +286,32 @@ public void onDisconnectRequested() {
@Override
public boolean onOptionsItemSelected(MenuItem menuItem) {
if (menuItem.getItemId() == android.R.id.home)
onBackPressed();
getOnBackPressedDispatcher().onBackPressed();

return (super.onOptionsItemSelected(menuItem));
}

@Override
public void onBackPressed() {
private void handleBack() {
FragmentManager fm = getSupportFragmentManager();
if (fm.getBackStackEntryCount() > 0) {
fm.popBackStack();
setTitle(mPrefs.getString(PREFS_DEFAULT_LOC_NAME, ""));
ActionBar ab = getSupportActionBar();
if (ab != null)
ab.setDisplayHomeAsUpEnabled(false);
} else
finish();
try {
mDetailFragment = (DeviceDetailFragment) mPreviousFragment;
} catch (ClassCastException ignored1) {
try {
mListFragment = (DeviceListFragment) mPreviousFragment;
} catch (ClassCastException ignored2) {
mDetailFragment = (DeviceDetailFragment) mPreviousFragment;
} catch (ClassCastException ignored1) {
try {
mListFragment = (DeviceListFragment) mPreviousFragment;
} catch (ClassCastException ignored2) {
}
}
} else {
// Nothing left on the back stack: hand control back to the platform
// default (finishing the activity) via the dispatcher.
mBackCallback.setEnabled(false);
getOnBackPressedDispatcher().onBackPressed();
}
}

Expand Down Expand Up @@ -378,16 +396,19 @@ public void onScanRequested() {
}

private void btEnable() {
BluetoothAdapter mBtAdapter;
mBtAdapter = BluetoothAdapter.getDefaultAdapter();
if (!mBtAdapter.isEnabled()) {
if (ActivityCompat.checkSelfPermission(this, Manifest.permission.BLUETOOTH_CONNECT) != PackageManager.PERMISSION_GRANTED) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
ActivityCompat.requestPermissions(getParent(), new String[]{Manifest.permission.BLUETOOTH_CONNECT}, 225);
}
BluetoothManager btManager = (BluetoothManager) getSystemService(Context.BLUETOOTH_SERVICE);
BluetoothAdapter btAdapter = btManager != null ? btManager.getAdapter() : null;
if (btAdapter == null) return; // device has no Bluetooth
if (!btAdapter.isEnabled()) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
&& ActivityCompat.checkSelfPermission(this, Manifest.permission.BLUETOOTH_CONNECT) != PackageManager.PERMISSION_GRANTED) {
ActivityCompat.requestPermissions(this, new String[]{Manifest.permission.BLUETOOTH_CONNECT}, 225);
return;
}
mBtAdapter.enable();
// BluetoothAdapter.enable() is deprecated and a no-op since Android
// 13 (apps can no longer silently toggle Bluetooth), so ask the user
// to enable it instead.
mBtEnableLauncher.launch(new Intent(BluetoothAdapter.ACTION_REQUEST_ENABLE));
}
}

Expand Down Expand Up @@ -445,9 +466,10 @@ public void onConfigurationChanged(@NonNull Configuration newConfig) {
break;
}

finish();
overridePendingTransition(0, 0);
startActivity(getIntent());
// Recreate the activity to apply the new theme. This is the modern
// equivalent of the previous finish()/startActivity() restart and keeps
// the task and back stack intact instead of tearing them down.
recreate();
}

static private class SynchronizationHandler extends Handler {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,14 @@ public class AsteroidBleManager extends BleManager {
// a smaller chunk size has no effect other than splitting into more writes.
private static final int MAX_ATTRIBUTE_LENGTH = 512;

// Request the largest ATT_MTU the BLE spec allows (517 bytes) at connection
// time. A bigger MTU means fewer, larger GATT writes and notifications, so
// syncs (notifications, screenshots, weather) transfer faster. This is only
// 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).


private static final DataSplitter CAPPED_SPLITTER = (message, index, maxLength) -> {
final int size = Math.min(maxLength, MAX_ATTRIBUTE_LENGTH);
final int offset = index * size;
Expand Down Expand Up @@ -80,8 +88,14 @@ public AsteroidBleManager(@NonNull final Context context, SynchronizationService
}

public final void send(UUID characteristic, byte[] data) {
writeCharacteristic(sendingCharacteristics.get(characteristic), data,
Objects.requireNonNull(sendingCharacteristics.get(characteristic)).getWriteType()).split(CAPPED_SPLITTER).enqueue();
if (sendingCharacteristics == null)
return;
BluetoothGattCharacteristic gattCharacteristic = sendingCharacteristics.get(characteristic);
if (gattCharacteristic == null) {
Log.w(TAG, "No writable characteristic for " + characteristic + "; dropping write");
return;
}
writeCharacteristic(gattCharacteristic, data, gattCharacteristic.getWriteType()).split(CAPPED_SPLITTER).enqueue();
}

@NonNull
Expand Down Expand Up @@ -138,6 +152,13 @@ public final boolean isRequiredServiceSupported(@NonNull final BluetoothGatt gat

for (IConnectivityService service : mSynchronizationService.getServices().values()) {
BluetoothGattService bluetoothGattService = gatt.getService(service.getServiceUUID());
// A flaky reconnect can return a partial GATT table. Skipping a
// missing service avoids a NullPointerException here that would
// abort the whole connection and trap us in a retry loop.
if (bluetoothGattService == null) {
Log.w(TAG, "Service not exposed by watch, skipping: " + service.getServiceUUID());
continue;
}
List<UUID> sendUuids = new ArrayList<>();
service.getCharacteristicUUIDs().forEach((uuid, direction) -> {
if (direction == IConnectivityService.Direction.TO_WATCH)
Expand All @@ -147,11 +168,13 @@ public final boolean isRequiredServiceSupported(@NonNull final BluetoothGatt gat

for (UUID uuid : sendUuids) {
BluetoothGattCharacteristic characteristic = bluetoothGattService.getCharacteristic(uuid);
sendingCharacteristics.put(uuid, characteristic);
bluetoothGattService.addCharacteristic(characteristic);
if (characteristic != null)
sendingCharacteristics.put(uuid, characteristic);
}
recvCallbacks.forEach((characteristic, callback) -> {
BluetoothGattCharacteristic characteristic1 = bluetoothGattService.getCharacteristic(characteristic);
if (characteristic1 == null)
return;
removeNotificationCallback(characteristic1);
setNotificationCallback(characteristic1).with((device, data) -> callback.call(data.getValue()));
enableNotifications(characteristic1).enqueue();
Expand All @@ -164,7 +187,7 @@ public final boolean isRequiredServiceSupported(@NonNull final BluetoothGatt gat
@Override
protected final void initialize() {
beginAtomicRequestQueue()
.add(requestMtu(256) // Remember, GATT needs 3 bytes extra. This will allow packet size of 244 bytes.
.add(requestMtu(GATT_MAX_MTU)
.with((device, mtu) -> log(Log.INFO, "MTU set to " + mtu))
.fail((device, status) -> log(Log.WARN, "Requested MTU not supported: " + status)))
.done(device -> log(Log.INFO, "Target initialized"))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ public MediaService(Context ctx, IAsteroidDevice device) {
mDevice = device;
mCtx = ctx;
device.registerCallback(AsteroidUUIDS.MEDIA_COMMANDS_CHAR, (data) -> {
if (data == null) return;
if (data == null || data.length < 1) return;
if (mMediaController != null) {
boolean isPoweramp = mSettings.getString(PREFS_MEDIA_CONTROLLER_PACKAGE, PREFS_MEDIA_CONTROLLER_PACKAGE_DEFAULT)
.equals(PowerampAPI.PACKAGE_NAME);
Expand Down Expand Up @@ -115,7 +115,7 @@ public MediaService(Context ctx, IAsteroidDevice device) {
}
break;
case MEDIA_COMMAND_VOLUME:
if (mMediaController.getPlaybackInfo() != null) {
if (data.length >= 2 && mMediaController.getPlaybackInfo() != null) {
if (data[1] != mVolume) {
int delta = Math.abs(mVolume - data[1]);
int deviceDelta = 100 / mMediaController.getPlaybackInfo().getMaxVolume();
Expand Down Expand Up @@ -200,15 +200,16 @@ private void sendVolume(int volume) {
mDevice.send(AsteroidUUIDS.MEDIA_VOLUME_CHAR, data, MediaService.this);
}

private final ContentObserver mVolumeChangeObserver = new ContentObserver(new Handler()) {
private final ContentObserver mVolumeChangeObserver = new ContentObserver(new Handler(Looper.getMainLooper())) {
// The last value of volume send to the watch.
private int reportedVolume;

@Override
public void onChange(boolean selfChange) {
super.onChange(selfChange);
if (mMediaController != null && mMediaController.getPlaybackInfo() != null) {
int vol = (100 * mMediaController.getPlaybackInfo().getCurrentVolume()) / mMediaController.getPlaybackInfo().getMaxVolume();
MediaController.PlaybackInfo info = mMediaController != null ? mMediaController.getPlaybackInfo() : null;
if (info != null && info.getMaxVolume() > 0) {
int vol = (100 * info.getCurrentVolume()) / info.getMaxVolume();

if (reportedVolume != vol) {
reportedVolume = vol;
Expand Down Expand Up @@ -269,8 +270,12 @@ public void onMetadataChanged(MediaMetadata metadata) {
getTextAsBytes(metadata, MediaMetadata.METADATA_KEY_TITLE),
MediaService.this);

mVolume = (100 * mMediaController.getPlaybackInfo().getCurrentVolume()) / mMediaController.getPlaybackInfo().getMaxVolume();
sendVolume(mVolume);
MediaController controller = mMediaController;
MediaController.PlaybackInfo info = controller != null ? controller.getPlaybackInfo() : null;
if (info != null && info.getMaxVolume() > 0) {
mVolume = (100 * info.getCurrentVolume()) / info.getMaxVolume();
sendVolume(mVolume);
}
}
}

Expand Down
Loading
Loading