Minor bug fixes; Documented PodsService

This commit is contained in:
adolfintel
2019-03-14 09:47:17 +01:00
parent af673910b0
commit 2b66a73874
5 changed files with 76 additions and 29 deletions
@@ -6,6 +6,7 @@
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
<uses-feature
android:name="android.hardware.bluetooth_le"
@@ -26,9 +26,9 @@ public class IntroActivity extends AppCompatActivity {
setContentView(R.layout.activity_intro);
((Button)findViewById(R.id.allowBtn)).setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View view) {
requestPermissions(new String[]{Manifest.permission.ACCESS_COARSE_LOCATION}, 1);
try {
public void onClick(View view) { //allow button clicked, ask for permissions
requestPermissions(new String[]{Manifest.permission.ACCESS_COARSE_LOCATION}, 1); //location (for BLE)
try { //run in background
if(!getSystemService(PowerManager.class).isIgnoringBatteryOptimizations(getPackageName())) {
Intent intent = new Intent();
PowerManager pm = (PowerManager) getSystemService(Context.POWER_SERVICE);
@@ -40,14 +40,15 @@ public class IntroActivity extends AppCompatActivity {
}
}
});
//wait for permissions to be granted. When they are granted, go to MainActivity
t= new Timer();
t.scheduleAtFixedRate(new TimerTask() {
@Override
public void run() {
boolean ok=true;
if (Build.VERSION.SDK_INT>= Build.VERSION_CODES.O) {
if(!getSystemService(PowerManager.class).isIgnoringBatteryOptimizations(getPackageName())) ok=false;
}
try {
if (!getSystemService(PowerManager.class).isIgnoringBatteryOptimizations(getPackageName())) ok = false;
}catch(Throwable t){}
if (ContextCompat.checkSelfPermission(IntroActivity.this, Manifest.permission.ACCESS_COARSE_LOCATION) != PackageManager.PERMISSION_GRANTED) ok=false;
if(ok){
t.cancel();
@@ -61,7 +62,7 @@ public class IntroActivity extends AppCompatActivity {
}
@Override
protected void onDestroy() {
protected void onDestroy() { //activity destroyed (or screen rotated). destroy the timer too
super.onDestroy();
if(t!=null) t.cancel();
}
@@ -23,6 +23,7 @@ public class MainActivity extends AppCompatActivity {
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
//check if Bluetooth LE is available on this device. If not, show an error
BluetoothAdapter btAdapter=((BluetoothManager) getSystemService(Context.BLUETOOTH_SERVICE)).getAdapter();
if(btAdapter==null||((BluetoothAdapter) btAdapter).getBluetoothLeScanner()==null){
Intent i=new Intent(this,NoBTActivity.class);
@@ -30,6 +31,7 @@ public class MainActivity extends AppCompatActivity {
finish();
return;
}
//check if all permissions have been granted
boolean ok=true;
try {
if (!getSystemService(PowerManager.class).isIgnoringBatteryOptimizations(getPackageName())) ok = false;
@@ -44,7 +46,7 @@ public class MainActivity extends AppCompatActivity {
}
((Button)(findViewById(R.id.mainHide))).setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View view) {
public void onClick(View view) { //hide app clicked
PackageManager p = getPackageManager();
p.setComponentEnabledSetting(new ComponentName(MainActivity.this,MainActivity.class), PackageManager.COMPONENT_ENABLED_STATE_DISABLED, PackageManager.DONT_KILL_APP);
Toast.makeText(getApplicationContext(),getString(R.string.hideClicked), Toast.LENGTH_LONG).show();
@@ -23,18 +23,45 @@ import android.widget.RemoteViews;
import java.util.ArrayList;
//todo: document how this class works
/**
* This is the class that does most of the work. It has 3 functions:
* - Detect when AirPods are detected
* - Receive beacons from AirPods and decode them (easier said than done thanks do google's autism)
* - Display the notification with the status
*
*/
public class PodsService extends Service {
private static final boolean ENABLE_LOGGING=BuildConfig.DEBUG;
private static final boolean ENABLE_LOGGING=BuildConfig.DEBUG; //Log is only displayed if this is a debug build, not release
private static NotificationThread n=null;
private static BluetoothLeScanner btScanner;
private static int leftStatus=15, rightStatus=15, caseStatus=15;
private static boolean chargeL=false, chargeR=false, chargeCase=false;
private static long lastSeenConnected=0;
private static final long TIMEOUT_CONNECTED=30000;
private static boolean maybeConnected =true;
/**
* The following method (startAirPodsScanner) creates a bluetoth LE scanner.
* This scanner receives all beacons from nearby BLE devices (not just your devices!) so we need to do 3 things:
* - Check that the beacon comes from something that looks like a pair of AirPods
* - Make sure that it is YOUR pair of AirPods
* - Decode the beacon to get the status
*
* On a normal OS, we would use the bluetooth address of the device to filter out beacons from other devices.
* UNFORTUNATELY, someone at google was so concerned about privacy (yea, as if they give a shit) that he decided it was a good idea to not allow access to the bluetooth address of incoming BLE beacons. As a result, we have no reliable way to make sure that the beacon comes from YOUR airpods and not the guy sitting next to you on the bus.
* What we did to workaround this issue is this:
* - When a beacon arrives that looks like a pair of AirPods, look at the other beacons received in the last 10 seconds and get the strongest one
* - If the strongest beacon's fake address is the same as this, use this beacon; otherwise use the strongest beacon
* - Decode...
*
* Decoding the beacon:
* This was done through reverse engineering. Hopefully it's correct.
* - The beacon coming from a pair of AirPods contains a manufacturer specific data field n°76 of 27 bytes
* - We convert this data to a hexadecimal string
* - The 12th and 13th characters in the string represent the charge of the left and right pods. Under unknown circumstances, they are right and left instead (see isFlipped). Values between 0 and 10 are battery 0-100%; Value 15 means it's disconnected
* - The 15th character in the string represents the charge of the case. Values between 0 and 10 are battery 0-100%; Value 15 means it's disconnected
* - The 14th character in the string represents the "in charge" status. Bit 0 (LSB) is the left pod; Bit 1 is the right pod; Bit 2 is the case. Bit 3 might be case open/closed but I'm not sure and it's not used
*
* After decoding a beacon, the status is written to leftStatus, rightStatus, caseStatus, chargeL, chargeR, chargeCase so that the NotificationThread can use the information
*
*/
private static ArrayList<ScanResult> recentBeacons=new ArrayList<>();
private static final long RECENT_BEACONS_MAX_T_NS=10000000000L; //10s
private void startAirPodsScanner() {
@@ -117,7 +144,20 @@ public class PodsService extends Service {
return (Integer.toString(Integer.parseInt(""+str.charAt(10),16)+0x10,2)).charAt(3)=='0';
}
/**
* The following class is a thread that manages the notification while your AirPods are connected.
*
* It simply reads the status variables every 1 seconds and creates, destroys, or updates the notification accordingly.
* The notification is shown when BT is on and AirPods are connected. The status is updated every 1 second. Battery% is hidden if we didn't receive a beacon for 30 seconds (screen off for a while)
*
* This thread is the reason why we need permission to disable doze. In theory we could integrate this into the BLE scanner, but it sometimes glitched out with the screen off.
*
*/
private static NotificationThread n=null;
private static final String TAG="AirPods";
private static long lastSeenConnected=0;
private static final long TIMEOUT_CONNECTED=30000;
private static boolean maybeConnected =true;
private class NotificationThread extends Thread{
public void run(){
boolean notificationShowing=false;
@@ -164,7 +204,6 @@ public class PodsService extends Service {
notificationSmall.setTextViewText(R.id.rightPodText, (rightStatus <= 10 ? (rightStatus * 10) + "%" : "") + (chargeR ? "+" : ""));
notificationSmall.setTextViewText(R.id.podCaseText, (caseStatus <= 10 ? (caseStatus * 10) + "%" : "") + (chargeCase ? "+" : ""));
}else{
//haven't received an update in a while (screen off), wait for an update before showing battery%
notificationBig.setTextViewText(R.id.leftPodText, "");
notificationBig.setTextViewText(R.id.rightPodText, "");
notificationBig.setTextViewText(R.id.podCaseText, "");
@@ -174,17 +213,13 @@ public class PodsService extends Service {
}
mNotifyManager.notify(1,mBuilder.build());
}
sleepMs(1000);
try {
Thread.sleep(1000);
} catch (InterruptedException e) { }
}
}
}
private static void sleepMs(long ms){
try {
Thread.sleep(ms);
} catch (InterruptedException e) { }
}
public PodsService() {
}
@@ -195,6 +230,10 @@ public class PodsService extends Service {
private BroadcastReceiver btReceiver=null;
/**
* When the service is created, we register to get as many bluetooth and airpods related events as possible.
* ACL_CONNECTED and ACL_DISCONNECTED should have been enough, but you never know with android these days.
*/
@Override
public void onCreate() {
super.onCreate();
@@ -217,22 +256,21 @@ public class PodsService extends Service {
String action = intent.getAction();
if(action.equals(BluetoothAdapter.ACTION_STATE_CHANGED)){
int state= intent.getIntExtra(BluetoothAdapter.EXTRA_STATE, BluetoothAdapter.ERROR);
if(state==BluetoothAdapter.STATE_OFF){
if(state==BluetoothAdapter.STATE_OFF){ //bluetooth turned off, stop scanner and remove notification
maybeConnected =false;
stopAirPodsScanner();
recentBeacons.clear();
//lastScanResult=null;
}
if(state==BluetoothAdapter.STATE_ON){
if(state==BluetoothAdapter.STATE_ON){ //bluetooth turned on, start/restart scanner
startAirPodsScanner();
}
}
if (bluetoothDevice != null && action != null && !action.isEmpty()&&checkUUID(bluetoothDevice)){
if(action.equals("android.bluetooth.device.action.ACL_CONNECTED")){
if (bluetoothDevice != null && action != null && !action.isEmpty()&&checkUUID(bluetoothDevice)){ //airpods filter
if(action.equals("android.bluetooth.device.action.ACL_CONNECTED")){ //airpods connected, show notification
if(ENABLE_LOGGING) Log.d(TAG,"ACL CONNECTED");
maybeConnected =true;
}
if(action.equals("android.bluetooth.device.action.ACL_DISCONNECTED")){
if(action.equals("android.bluetooth.device.action.ACL_DISCONNECTED")){ //airpods disconnected, remove notification but leave the scanner going
if(ENABLE_LOGGING) Log.d(TAG,"ACL DISCONNECTED");
maybeConnected =false;
recentBeacons.clear();
@@ -246,7 +284,7 @@ public class PodsService extends Service {
try{
registerReceiver(btReceiver,intentFilter);
}catch(Throwable t){}
if(((BluetoothManager) getSystemService(Context.BLUETOOTH_SERVICE)).getAdapter().isEnabled())startAirPodsScanner();
if(((BluetoothManager) getSystemService(Context.BLUETOOTH_SERVICE)).getAdapter().isEnabled())startAirPodsScanner(); //if BT is already on when the app is started, start the scanner without waiting for an event to happen
}
private boolean checkUUID(BluetoothDevice bluetoothDevice){
@@ -3,10 +3,15 @@ package com.dosse.airpods;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import android.util.Log;
/**
* A simple starter class that starts the service when the device is booted, or after an update
*/
public class Starter extends BroadcastReceiver {
@Override
public void onReceive(Context context, Intent intent) {
Log.d("PODS","BOOT COMPLETE!!!!!!!!");
context.startService(new Intent(context,PodsService.class));
}
}