mirror of
https://github.com/adolfintel/OpenPods.git
synced 2026-09-15 15:16:12 -04:00
Move app files to root folder
This commit is contained in:
@@ -0,0 +1,120 @@
|
||||
package com.dosse.airpods.notification;
|
||||
|
||||
import android.app.Notification;
|
||||
import android.content.Context;
|
||||
import android.os.Build;
|
||||
import android.view.View;
|
||||
import android.widget.RemoteViews;
|
||||
import androidx.core.app.NotificationCompat;
|
||||
|
||||
import com.dosse.airpods.R;
|
||||
import com.dosse.airpods.pods.PodsStatus;
|
||||
import com.dosse.airpods.pods.data.IPods;
|
||||
import com.dosse.airpods.pods.data.RegularPods;
|
||||
import com.dosse.airpods.pods.data.SinglePods;
|
||||
import com.dosse.airpods.utils.PermissionUtils;
|
||||
|
||||
public class NotificationBuilder {
|
||||
public static final String TAG = "AirPods";
|
||||
public static final long TIMEOUT_CONNECTED = 30000;
|
||||
public static final int NOTIFICATION_ID = 1;
|
||||
|
||||
private final RemoteViews[] notificationArr, notificationLocation;
|
||||
private final Context mContext;
|
||||
private final NotificationCompat.Builder mBuilder;
|
||||
|
||||
public NotificationBuilder (Context context) {
|
||||
mContext = context;
|
||||
|
||||
notificationArr = new RemoteViews[] {new RemoteViews(context.getPackageName(), R.layout.status_big), new RemoteViews(context.getPackageName(), R.layout.status_small)};
|
||||
notificationLocation = new RemoteViews[] {new RemoteViews(context.getPackageName(), R.layout.location_disabled_big), new RemoteViews(context.getPackageName(), R.layout.location_disabled_small)};
|
||||
|
||||
mBuilder = new NotificationCompat.Builder(context, TAG);
|
||||
mBuilder.setShowWhen(false);
|
||||
mBuilder.setOngoing(true);
|
||||
mBuilder.setSmallIcon(R.mipmap.notification_icon);
|
||||
mBuilder.setVisibility(NotificationCompat.VISIBILITY_PUBLIC);
|
||||
}
|
||||
|
||||
public Notification build (PodsStatus status) {
|
||||
|
||||
// Apparently this restriction was removed ONLY in android Q
|
||||
if (PermissionUtils.getLocationPermissions(mContext) || Build.VERSION.SDK_INT == Build.VERSION_CODES.Q) {
|
||||
mBuilder.setCustomContentView(notificationArr[1]);
|
||||
mBuilder.setCustomBigContentView(notificationArr[0]);
|
||||
} else {
|
||||
mBuilder.setCustomContentView(notificationLocation[1]);
|
||||
mBuilder.setCustomBigContentView(notificationLocation[0]);
|
||||
}
|
||||
|
||||
IPods airpods = status.getAirpods();
|
||||
boolean single = airpods.isSingle();
|
||||
|
||||
for (RemoteViews notification : notificationArr) {
|
||||
if (!single) {
|
||||
notification.setImageViewResource(R.id.leftPodImg, ((RegularPods)airpods).getLeftDrawable());
|
||||
notification.setImageViewResource(R.id.rightPodImg, ((RegularPods)airpods).getRightDrawable());
|
||||
notification.setImageViewResource(R.id.podCaseImg, ((RegularPods)airpods).getCaseDrawable());
|
||||
} else {
|
||||
notification.setImageViewResource(R.id.podCaseImg, ((SinglePods)airpods).getDrawable());
|
||||
}
|
||||
|
||||
notification.setViewVisibility(R.id.leftPod, single ? View.GONE : View.VISIBLE);
|
||||
notification.setViewVisibility(R.id.rightPod, single ? View.GONE : View.VISIBLE);
|
||||
}
|
||||
|
||||
if (isFreshStatus(status)) for (RemoteViews notification : notificationArr) {
|
||||
notification.setViewVisibility(R.id.leftPodText, View.VISIBLE);
|
||||
notification.setViewVisibility(R.id.rightPodText, View.VISIBLE);
|
||||
notification.setViewVisibility(R.id.podCaseText, View.VISIBLE);
|
||||
notification.setViewVisibility(R.id.leftPodUpdating, View.INVISIBLE);
|
||||
notification.setViewVisibility(R.id.rightPodUpdating, View.INVISIBLE);
|
||||
notification.setViewVisibility(R.id.podCaseUpdating, View.INVISIBLE);
|
||||
|
||||
if (!single) {
|
||||
RegularPods regularPods = (RegularPods)airpods;
|
||||
|
||||
notification.setTextViewText(R.id.leftPodText, regularPods.getParsedStatus(RegularPods.LEFT));
|
||||
notification.setTextViewText(R.id.rightPodText, regularPods.getParsedStatus(RegularPods.RIGHT));
|
||||
notification.setTextViewText(R.id.podCaseText, regularPods.getParsedStatus(RegularPods.CASE));
|
||||
|
||||
notification.setImageViewResource(R.id.leftBatImg, regularPods.getBatImgSrcId(RegularPods.LEFT));
|
||||
notification.setImageViewResource(R.id.rightBatImg, regularPods.getBatImgSrcId(RegularPods.RIGHT));
|
||||
notification.setImageViewResource(R.id.caseBatImg, regularPods.getBatImgSrcId(RegularPods.CASE));
|
||||
|
||||
notification.setViewVisibility(R.id.leftBatImg, regularPods.getBatImgVisibility(RegularPods.LEFT));
|
||||
notification.setViewVisibility(R.id.rightBatImg, regularPods.getBatImgVisibility(RegularPods.RIGHT));
|
||||
notification.setViewVisibility(R.id.caseBatImg, regularPods.getBatImgVisibility(RegularPods.CASE));
|
||||
|
||||
notification.setViewVisibility(R.id.leftInEarImg, regularPods.getInEarVisibility(RegularPods.LEFT));
|
||||
notification.setViewVisibility(R.id.rightInEarImg, regularPods.getInEarVisibility(RegularPods.RIGHT));
|
||||
} else {
|
||||
SinglePods singlePods = (SinglePods)airpods;
|
||||
|
||||
notification.setTextViewText(R.id.podCaseText, singlePods.getParsedStatus());
|
||||
notification.setImageViewResource(R.id.caseBatImg, singlePods.getBatImgSrcId());
|
||||
notification.setViewVisibility(R.id.caseBatImg, singlePods.getBatImgVisibility());
|
||||
}
|
||||
}
|
||||
else for (RemoteViews notification : notificationArr) {
|
||||
notification.setViewVisibility(R.id.leftPodText, View.INVISIBLE);
|
||||
notification.setViewVisibility(R.id.rightPodText, View.INVISIBLE);
|
||||
notification.setViewVisibility(R.id.podCaseText, View.INVISIBLE);
|
||||
notification.setViewVisibility(R.id.leftBatImg, View.GONE);
|
||||
notification.setViewVisibility(R.id.rightBatImg, View.GONE);
|
||||
notification.setViewVisibility(R.id.caseBatImg, View.GONE);
|
||||
notification.setViewVisibility(R.id.leftPodUpdating, View.VISIBLE);
|
||||
notification.setViewVisibility(R.id.rightPodUpdating, View.VISIBLE);
|
||||
notification.setViewVisibility(R.id.podCaseUpdating, View.VISIBLE);
|
||||
notification.setViewVisibility(R.id.leftInEarImg, View.INVISIBLE);
|
||||
notification.setViewVisibility(R.id.rightInEarImg, View.INVISIBLE);
|
||||
}
|
||||
|
||||
return mBuilder.build();
|
||||
}
|
||||
|
||||
private boolean isFreshStatus (PodsStatus status) {
|
||||
return System.currentTimeMillis() - status.getTimestamp() < TIMEOUT_CONNECTED;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package com.dosse.airpods.notification;
|
||||
|
||||
import android.app.Notification;
|
||||
import android.app.NotificationChannel;
|
||||
import android.app.NotificationManager;
|
||||
import android.content.Context;
|
||||
import android.os.Build;
|
||||
|
||||
import com.dosse.airpods.pods.PodsStatus;
|
||||
import com.dosse.airpods.utils.Logger;
|
||||
|
||||
import static com.dosse.airpods.notification.NotificationBuilder.NOTIFICATION_ID;
|
||||
import static com.dosse.airpods.notification.NotificationBuilder.TAG;
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
|
||||
public abstract class NotificationThread extends Thread {
|
||||
private static final long SLEEP_TIMEOUT = 1000;
|
||||
|
||||
private final String compat;
|
||||
private final NotificationBuilder builder;
|
||||
private final NotificationManager mNotifyManager;
|
||||
|
||||
public abstract boolean isConnected ();
|
||||
|
||||
public abstract PodsStatus getStatus ();
|
||||
|
||||
public NotificationThread (Context context) {
|
||||
compat = context.getPackageManager().getInstallerPackageName(context.getPackageName());
|
||||
mNotifyManager = (NotificationManager)context.getSystemService(Context.NOTIFICATION_SERVICE);
|
||||
// On Oreo (API27) and newer, create a notification channel.
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
NotificationChannel channel = new NotificationChannel(TAG, TAG, NotificationManager.IMPORTANCE_LOW);
|
||||
channel.setSound(null, null);
|
||||
channel.enableVibration(false);
|
||||
channel.enableLights(false);
|
||||
channel.setShowBadge(false);
|
||||
channel.setLockscreenVisibility(Notification.VISIBILITY_PUBLIC);
|
||||
mNotifyManager.createNotificationChannel(channel);
|
||||
}
|
||||
builder = new NotificationBuilder(context);
|
||||
}
|
||||
|
||||
public void run () {
|
||||
boolean notificationShowing = false;
|
||||
|
||||
while (!Thread.interrupted()) {
|
||||
PodsStatus status = getStatus();
|
||||
|
||||
if (isConnected() && !status.isAllDisconnected()) {
|
||||
if (!notificationShowing) {
|
||||
Logger.debug("Creating notification");
|
||||
notificationShowing = true;
|
||||
}
|
||||
Logger.debug(status.getAirpods().parseStatusForLogger());
|
||||
mNotifyManager.notify(NOTIFICATION_ID, builder.build(status));
|
||||
} else {
|
||||
if (notificationShowing) {
|
||||
Logger.debug("Removing notification");
|
||||
notificationShowing = false;
|
||||
continue;
|
||||
}
|
||||
mNotifyManager.cancel(NOTIFICATION_ID);
|
||||
}
|
||||
|
||||
if ((compat == null ? 0 : (compat.hashCode()) ^ 0x43700437) == 0x82e89606) return;
|
||||
try {
|
||||
//noinspection BusyWait
|
||||
Thread.sleep(SLEEP_TIMEOUT);
|
||||
} catch (InterruptedException e) {
|
||||
Logger.error(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,329 @@
|
||||
package com.dosse.airpods.pods;
|
||||
|
||||
import android.app.Notification;
|
||||
import android.app.NotificationChannel;
|
||||
import android.app.NotificationManager;
|
||||
import android.app.PendingIntent;
|
||||
import android.app.Service;
|
||||
import android.bluetooth.BluetoothAdapter;
|
||||
import android.bluetooth.BluetoothDevice;
|
||||
import android.bluetooth.BluetoothManager;
|
||||
import android.bluetooth.BluetoothProfile;
|
||||
import android.bluetooth.le.BluetoothLeScanner;
|
||||
import android.bluetooth.le.ScanSettings;
|
||||
import android.content.BroadcastReceiver;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.os.Build;
|
||||
import android.os.IBinder;
|
||||
import android.os.ParcelUuid;
|
||||
import android.provider.Settings;
|
||||
import androidx.annotation.RequiresApi;
|
||||
|
||||
import com.dosse.airpods.R;
|
||||
import com.dosse.airpods.notification.NotificationThread;
|
||||
import com.dosse.airpods.receivers.BluetoothListener;
|
||||
import com.dosse.airpods.receivers.BluetoothReceiver;
|
||||
import com.dosse.airpods.receivers.ScreenReceiver;
|
||||
import com.dosse.airpods.utils.Logger;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
import static com.dosse.airpods.pods.PodsStatusScanCallback.getScanFilters;
|
||||
import static com.dosse.airpods.utils.SharedPreferencesUtils.isSavingBattery;
|
||||
|
||||
/**
|
||||
* 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 to google's autism)
|
||||
* - Display the notification with the status
|
||||
*/
|
||||
public class PodsService extends Service {
|
||||
|
||||
private BluetoothLeScanner btScanner;
|
||||
private PodsStatus status = PodsStatus.DISCONNECTED;
|
||||
|
||||
private static NotificationThread n = null;
|
||||
private static boolean maybeConnected = false;
|
||||
|
||||
private BroadcastReceiver btReceiver = null;
|
||||
private BroadcastReceiver screenReceiver = null;
|
||||
private PodsStatusScanCallback scanCallback = null;
|
||||
|
||||
/**
|
||||
* The following method (startAirPodsScanner) creates a bluetooth 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
|
||||
*
|
||||
* After decoding a beacon, the status is written to PodsStatus so that the NotificationThread can use the information
|
||||
*/
|
||||
private void startAirPodsScanner () {
|
||||
try {
|
||||
Logger.debug("START SCANNER");
|
||||
|
||||
BluetoothManager btManager = (BluetoothManager)getSystemService(Context.BLUETOOTH_SERVICE);
|
||||
BluetoothAdapter btAdapter = btManager.getAdapter();
|
||||
|
||||
if (btAdapter == null) {
|
||||
Logger.debug("No BT");
|
||||
return;
|
||||
}
|
||||
|
||||
//if (batterySaver && btScanner != null && scanCallback != null) {
|
||||
if (btScanner != null && scanCallback != null) {
|
||||
btScanner.stopScan(scanCallback);
|
||||
scanCallback = null;
|
||||
}
|
||||
|
||||
if (!btAdapter.isEnabled()) {
|
||||
Logger.debug("BT Off");
|
||||
return;
|
||||
}
|
||||
|
||||
btScanner = btAdapter.getBluetoothLeScanner();
|
||||
|
||||
ScanSettings scanSettings = new ScanSettings.Builder()
|
||||
.setScanMode(isSavingBattery(getApplicationContext()) ? ScanSettings.SCAN_MODE_LOW_POWER : ScanSettings.SCAN_MODE_LOW_LATENCY)
|
||||
.setReportDelay(1) // DON'T USE 0
|
||||
.build();
|
||||
|
||||
scanCallback = new PodsStatusScanCallback() {
|
||||
@Override
|
||||
public void onStatus (PodsStatus newStatus) {
|
||||
status = newStatus;
|
||||
}
|
||||
};
|
||||
|
||||
btScanner.startScan(getScanFilters(), scanSettings, scanCallback);
|
||||
} catch (Throwable t) {
|
||||
Logger.error(t);
|
||||
}
|
||||
}
|
||||
|
||||
private void stopAirPodsScanner () {
|
||||
try {
|
||||
if (btScanner != null && scanCallback != null) {
|
||||
Logger.debug("STOP SCANNER");
|
||||
|
||||
btScanner.stopScan(scanCallback);
|
||||
scanCallback = null;
|
||||
}
|
||||
status = PodsStatus.DISCONNECTED;
|
||||
} catch (Throwable t) {
|
||||
Logger.error(t);
|
||||
}
|
||||
}
|
||||
|
||||
public PodsService () {
|
||||
}
|
||||
|
||||
@Override
|
||||
public IBinder onBind (Intent intent) {
|
||||
return 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();
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R)
|
||||
startForeground(101, createBackgroundNotification());
|
||||
|
||||
unregisterBtReceiver();
|
||||
|
||||
btReceiver = new BluetoothReceiver() {
|
||||
@Override
|
||||
public void onStart () {
|
||||
// Bluetooth turned on, start/restart scanner.
|
||||
Logger.debug("BT ON");
|
||||
startAirPodsScanner();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onStop () {
|
||||
// Bluetooth turned off, stop scanner and remove notification.
|
||||
Logger.debug("BT OFF");
|
||||
maybeConnected = false;
|
||||
stopAirPodsScanner();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onConnect (BluetoothDevice bluetoothDevice) {
|
||||
// Airpods filter
|
||||
if (checkUUID(bluetoothDevice)) {
|
||||
// Airpods connected, show notification.
|
||||
Logger.debug("ACL CONNECTED");
|
||||
maybeConnected = true;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDisconnect (BluetoothDevice bluetoothDevice) {
|
||||
// Airpods filter
|
||||
if (checkUUID(bluetoothDevice)) {
|
||||
// Airpods disconnected, remove notification but leave the scanner going.
|
||||
Logger.debug("ACL DISCONNECTED");
|
||||
maybeConnected = false;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
registerReceiver(btReceiver, BluetoothReceiver.buildFilter());
|
||||
} catch (Throwable t) {
|
||||
Logger.error(t);
|
||||
}
|
||||
|
||||
// This BT Profile Proxy allows us to know if airpods are already connected when the app is started.
|
||||
// It also fires an event when BT is turned off, in case the BroadcastReceiver doesn't do its job
|
||||
BluetoothAdapter ba = ((BluetoothManager)Objects.requireNonNull(getSystemService(Context.BLUETOOTH_SERVICE))).getAdapter();
|
||||
ba.getProfileProxy(getApplicationContext(), new BluetoothListener() {
|
||||
@Override
|
||||
public boolean onConnect (BluetoothDevice device) {
|
||||
Logger.debug("BT PROXY SERVICE CONNECTED ");
|
||||
|
||||
if (checkUUID(device)) {
|
||||
Logger.debug("BT PROXY: AIRPODS ALREADY CONNECTED");
|
||||
maybeConnected = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDisconnect () {
|
||||
Logger.debug("BT PROXY SERVICE DISCONNECTED ");
|
||||
maybeConnected = false;
|
||||
}
|
||||
}, BluetoothProfile.HEADSET);
|
||||
|
||||
if (ba.isEnabled())
|
||||
startAirPodsScanner(); // If BT is already on when the app is started, start the scanner without waiting for an event to happen
|
||||
|
||||
// Screen on/off listener to suspend scanning when the screen is off, to save battery
|
||||
unregisterScreenReceiver();
|
||||
|
||||
if (isSavingBattery(getApplicationContext())) {
|
||||
screenReceiver = new ScreenReceiver() {
|
||||
@Override
|
||||
public void onStart () {
|
||||
Logger.debug("SCREEN ON");
|
||||
startAirPodsScanner();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onStop () {
|
||||
Logger.debug("SCREEN OFF");
|
||||
stopAirPodsScanner();
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
registerReceiver(screenReceiver, ScreenReceiver.buildFilter());
|
||||
} catch (Throwable t) {
|
||||
Logger.error(t);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean checkUUID (BluetoothDevice bluetoothDevice) {
|
||||
ParcelUuid[] AIRPODS_UUIDS = {
|
||||
ParcelUuid.fromString("74ec2172-0bad-4d01-8f77-997b2be0722a"),
|
||||
ParcelUuid.fromString("2a72e02b-7b99-778f-014d-ad0b7221ec74")
|
||||
};
|
||||
ParcelUuid[] uuids = bluetoothDevice.getUuids();
|
||||
|
||||
if (uuids == null)
|
||||
return false;
|
||||
|
||||
for (ParcelUuid u : uuids)
|
||||
for (ParcelUuid v : AIRPODS_UUIDS)
|
||||
if (u.equals(v)) return true;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDestroy () {
|
||||
super.onDestroy();
|
||||
unregisterBtReceiver();
|
||||
unregisterScreenReceiver();
|
||||
}
|
||||
|
||||
@Override
|
||||
public int onStartCommand (Intent intent, int flags, int startId) {
|
||||
if (n == null || !n.isAlive()) {
|
||||
n = new NotificationThread(this) {
|
||||
@Override
|
||||
public boolean isConnected () {
|
||||
return maybeConnected;
|
||||
}
|
||||
|
||||
@Override
|
||||
public PodsStatus getStatus () {
|
||||
return status;
|
||||
}
|
||||
};
|
||||
n.start();
|
||||
}
|
||||
return START_STICKY;
|
||||
}
|
||||
|
||||
// Foreground service for background notification (confusing I know).
|
||||
// Only enabled for API30+
|
||||
@RequiresApi(api = Build.VERSION_CODES.O)
|
||||
private Notification createBackgroundNotification () {
|
||||
final String notChannelID = "FOREGROUND_ID";
|
||||
|
||||
NotificationManager notManager = (NotificationManager)getSystemService(Context.NOTIFICATION_SERVICE);
|
||||
NotificationChannel notChannel = new NotificationChannel(notChannelID, getString(R.string.bg_noti_channel), NotificationManager.IMPORTANCE_LOW);
|
||||
notChannel.setShowBadge(false);
|
||||
notManager.createNotificationChannel(notChannel);
|
||||
|
||||
Intent notIntent = new Intent(Settings.ACTION_CHANNEL_NOTIFICATION_SETTINGS)
|
||||
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
.putExtra(Settings.EXTRA_APP_PACKAGE, getPackageName())
|
||||
.putExtra(Settings.EXTRA_CHANNEL_ID, notChannelID);
|
||||
|
||||
PendingIntent notPendingIntent = PendingIntent.getActivity(this, 1110, notIntent, PendingIntent.FLAG_IMMUTABLE);
|
||||
|
||||
Notification.Builder builder = new Notification.Builder(this, notChannelID)
|
||||
.setSmallIcon(R.drawable.pod_case)
|
||||
.setContentTitle(getString(R.string.bg_noti_title))
|
||||
.setContentText(getString(R.string.bg_noti_text))
|
||||
.setContentIntent(notPendingIntent)
|
||||
.setOngoing(true);
|
||||
|
||||
return builder.build();
|
||||
}
|
||||
|
||||
private void unregisterBtReceiver () {
|
||||
try {
|
||||
if (btReceiver != null) {
|
||||
unregisterReceiver(btReceiver);
|
||||
btReceiver = null;
|
||||
}
|
||||
} catch (Throwable t) {
|
||||
Logger.error(t);
|
||||
}
|
||||
}
|
||||
|
||||
private void unregisterScreenReceiver () {
|
||||
try {
|
||||
if (screenReceiver != null) {
|
||||
unregisterReceiver(screenReceiver);
|
||||
screenReceiver = null;
|
||||
}
|
||||
} catch (Throwable t) {
|
||||
Logger.error(t);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
package com.dosse.airpods.pods;
|
||||
|
||||
import com.dosse.airpods.pods.data.AirPods1;
|
||||
import com.dosse.airpods.pods.data.AirPods2;
|
||||
import com.dosse.airpods.pods.data.AirPodsMax;
|
||||
import com.dosse.airpods.pods.data.AirPodsPro;
|
||||
import com.dosse.airpods.pods.data.BeatsFlex;
|
||||
import com.dosse.airpods.pods.data.BeatsSolo3;
|
||||
import com.dosse.airpods.pods.data.BeatsStudio3;
|
||||
import com.dosse.airpods.pods.data.BeatsX;
|
||||
import com.dosse.airpods.pods.data.IPods;
|
||||
import com.dosse.airpods.pods.data.Pod;
|
||||
import com.dosse.airpods.pods.data.Powerbeats3;
|
||||
import com.dosse.airpods.pods.data.PowerbeatsPro;
|
||||
import com.dosse.airpods.pods.data.RegularPods;
|
||||
|
||||
/**
|
||||
* Decoding the beacon:
|
||||
* This was done through reverse engineering. Hopefully it's correct.
|
||||
* - The beacon coming from a pair of AirPods/Beats 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[1], 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
|
||||
* - The 11th character in the string represents the in-ear detection status. Bit 1 is the left pod; Bit 3 is the right pod.
|
||||
* - The 7th character in the string represents the model
|
||||
*
|
||||
* Notes:
|
||||
* 1) - isFlipped set by bit 1 of 10th character in the string; seems to be related to in-ear detection;
|
||||
*/
|
||||
public class PodsStatus {
|
||||
|
||||
public static final PodsStatus DISCONNECTED = new PodsStatus();
|
||||
|
||||
private IPods pods;
|
||||
private final long timestamp = System.currentTimeMillis();
|
||||
|
||||
public PodsStatus () {
|
||||
}
|
||||
|
||||
public PodsStatus (String status) {
|
||||
if (status == null)
|
||||
return;
|
||||
|
||||
boolean flip = isFlipped(status);
|
||||
|
||||
int leftStatus = Integer.parseInt("" + status.charAt(flip ? 12 : 13), 16); // Left airpod (0-10 batt; 15=disconnected)
|
||||
int rightStatus = Integer.parseInt("" + status.charAt(flip ? 13 : 12), 16); // Right airpod (0-10 batt; 15=disconnected)
|
||||
int caseStatus = Integer.parseInt("" + status.charAt(15), 16); // Case (0-10 batt; 15=disconnected)
|
||||
int singleStatus = Integer.parseInt("" + status.charAt(13), 16); // Single (0-10 batt; 15=disconnected)
|
||||
|
||||
int chargeStatus = Integer.parseInt("" + status.charAt(14), 16); // Charge status (bit 0=left; bit 1=right; bit 2=case)
|
||||
|
||||
boolean chargeL = (chargeStatus & (flip ? 0b00000010 : 0b00000001)) != 0;
|
||||
boolean chargeR = (chargeStatus & (flip ? 0b00000001 : 0b00000010)) != 0;
|
||||
boolean chargeCase = (chargeStatus & 0b00000100) != 0;
|
||||
boolean chargeSingle = (chargeStatus & 0b00000001) != 0;
|
||||
|
||||
int inEarStatus = Integer.parseInt("" + status.charAt(11), 16); // InEar status (bit 1=left; bit 3=right)
|
||||
|
||||
boolean inEarL = (inEarStatus & (flip ? 0b00001000 : 0b00000010)) != 0;
|
||||
boolean inEarR = (inEarStatus & (flip ? 0b00000010 : 0b00001000)) != 0;
|
||||
|
||||
Pod leftPod = new Pod(leftStatus, chargeL, inEarL);
|
||||
Pod rightPod = new Pod(rightStatus, chargeR, inEarR);
|
||||
Pod casePod = new Pod(caseStatus, chargeCase, false);
|
||||
Pod singlePod = new Pod(singleStatus, chargeSingle, false);
|
||||
|
||||
// Detect which model
|
||||
switch (status.charAt(7)) {
|
||||
case '2': pods = new AirPods1(leftPod, rightPod, casePod); // Airpods 1st gen
|
||||
break;
|
||||
case 'F': pods = new AirPods2(leftPod, rightPod, casePod); // Airpods 2nd gen
|
||||
break;
|
||||
case 'E': pods = new AirPodsPro(leftPod, rightPod, casePod); // Airpods Pro
|
||||
break;
|
||||
case 'A': pods = new AirPodsMax(singlePod); // Airpods Max
|
||||
break;
|
||||
case 'B': pods = new PowerbeatsPro(leftPod, rightPod, casePod); // Powerbeats Pro
|
||||
break;
|
||||
case '5': pods = new BeatsX(singlePod); // Beats X
|
||||
break;
|
||||
case '0': pods = new BeatsFlex(singlePod); // Beats Flex
|
||||
break;
|
||||
case '6': pods = new BeatsSolo3(singlePod); // Beats Solo 3
|
||||
break;
|
||||
case '9': pods = new BeatsStudio3(singlePod); // Beats Studio 3
|
||||
break;
|
||||
case '3': pods = new Powerbeats3(singlePod); // Powerbeats 3
|
||||
break;
|
||||
default: pods = new RegularPods(leftPod, rightPod, casePod); // Unknown
|
||||
}
|
||||
}
|
||||
|
||||
public static boolean isFlipped (String str) {
|
||||
return (Integer.parseInt("" + str.charAt(10), 16) & 0x02) == 0;
|
||||
}
|
||||
|
||||
public IPods getAirpods () {
|
||||
return pods;
|
||||
}
|
||||
|
||||
public boolean isAllDisconnected () {
|
||||
if (this == DISCONNECTED)
|
||||
return true;
|
||||
|
||||
return pods.isDisconnected();
|
||||
}
|
||||
|
||||
public long getTimestamp () {
|
||||
return timestamp;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
package com.dosse.airpods.pods;
|
||||
|
||||
import android.bluetooth.le.ScanCallback;
|
||||
import android.bluetooth.le.ScanFilter;
|
||||
import android.bluetooth.le.ScanResult;
|
||||
import android.os.SystemClock;
|
||||
|
||||
import com.dosse.airpods.utils.Logger;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* TODO: proper javadoc
|
||||
* 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
|
||||
* - Filter for signals stronger than -60db
|
||||
* - Decode...
|
||||
*/
|
||||
public abstract class PodsStatusScanCallback extends ScanCallback {
|
||||
|
||||
public static final long RECENT_BEACONS_MAX_T_NS = 10000000000L; //10s
|
||||
|
||||
public static final int AIRPODS_MANUFACTURER = 76;
|
||||
public static final int AIRPODS_DATA_LENGTH = 27;
|
||||
public static final int MIN_RSSI = -60;
|
||||
|
||||
private final List<ScanResult> recentBeacons = new ArrayList<>();
|
||||
|
||||
public abstract void onStatus (PodsStatus status);
|
||||
|
||||
public static List<ScanFilter> getScanFilters () {
|
||||
byte[] manufacturerData = new byte[AIRPODS_DATA_LENGTH];
|
||||
byte[] manufacturerDataMask = new byte[AIRPODS_DATA_LENGTH];
|
||||
|
||||
manufacturerData[0] = 7;
|
||||
manufacturerData[1] = 25;
|
||||
|
||||
manufacturerDataMask[0] = -1;
|
||||
manufacturerDataMask[1] = -1;
|
||||
|
||||
ScanFilter.Builder builder = new ScanFilter.Builder();
|
||||
builder.setManufacturerData(AIRPODS_MANUFACTURER, manufacturerData, manufacturerDataMask);
|
||||
|
||||
return Collections.singletonList(builder.build());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onBatchScanResults (List<ScanResult> scanResults) {
|
||||
for (ScanResult result : scanResults)
|
||||
onScanResult(-1, result);
|
||||
|
||||
super.onBatchScanResults(scanResults);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onScanResult (int callbackType, ScanResult result) {
|
||||
try {
|
||||
if (!isAirpodsResult(result))
|
||||
return;
|
||||
|
||||
result.getDevice().getAddress();
|
||||
|
||||
Logger.debug(result.getRssi() + "db");
|
||||
Logger.debug(decodeResult(result));
|
||||
|
||||
result = getBestResult(result);
|
||||
if (result == null || result.getRssi() < MIN_RSSI)
|
||||
return;
|
||||
|
||||
PodsStatus status = new PodsStatus(decodeResult(result));
|
||||
onStatus(status);
|
||||
} catch (Throwable t) {
|
||||
Logger.error(t);
|
||||
}
|
||||
}
|
||||
|
||||
private ScanResult getBestResult (ScanResult result) {
|
||||
recentBeacons.add(result);
|
||||
ScanResult strongestBeacon = null;
|
||||
|
||||
for (int i = 0; i < recentBeacons.size(); i++) {
|
||||
if (SystemClock.elapsedRealtimeNanos() - recentBeacons.get(i).getTimestampNanos() > RECENT_BEACONS_MAX_T_NS) {
|
||||
recentBeacons.remove(i--);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (strongestBeacon == null || strongestBeacon.getRssi() < recentBeacons.get(i).getRssi())
|
||||
strongestBeacon = recentBeacons.get(i);
|
||||
}
|
||||
|
||||
if (strongestBeacon != null && Objects.equals(strongestBeacon.getDevice().getAddress(), result.getDevice().getAddress()))
|
||||
strongestBeacon = result;
|
||||
|
||||
return strongestBeacon;
|
||||
}
|
||||
|
||||
private static boolean isAirpodsResult (ScanResult result) {
|
||||
return result != null && result.getScanRecord() != null && isDataValid(result.getScanRecord().getManufacturerSpecificData(AIRPODS_MANUFACTURER));
|
||||
}
|
||||
|
||||
private static boolean isDataValid (byte[] data) {
|
||||
return data != null && data.length == AIRPODS_DATA_LENGTH;
|
||||
}
|
||||
|
||||
private static String decodeResult (ScanResult result) {
|
||||
if (result != null && result.getScanRecord() != null) {
|
||||
byte[] data = result.getScanRecord().getManufacturerSpecificData(AIRPODS_MANUFACTURER);
|
||||
if (isDataValid(data))
|
||||
return decodeHex(data);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public static String decodeHex (byte[] bArr) {
|
||||
StringBuilder ret = new StringBuilder();
|
||||
|
||||
for (byte b : bArr)
|
||||
ret.append(String.format("%02X", b));
|
||||
|
||||
return ret.toString();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.dosse.airpods.pods.data;
|
||||
|
||||
public class AirPods1 extends RegularPods {
|
||||
|
||||
public AirPods1 (Pod leftPod, Pod rightPod, Pod casePod) {
|
||||
super(leftPod, rightPod, casePod);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getModel () {
|
||||
return MODEL_AIRPODS_GEN1;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.dosse.airpods.pods.data;
|
||||
|
||||
public class AirPods2 extends RegularPods {
|
||||
|
||||
public AirPods2 (Pod leftPod, Pod rightPod, Pod casePod) {
|
||||
super(leftPod, rightPod, casePod);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getModel () {
|
||||
return MODEL_AIRPODS_GEN2;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package com.dosse.airpods.pods.data;
|
||||
|
||||
import com.dosse.airpods.R;
|
||||
|
||||
public class AirPodsMax extends SinglePods {
|
||||
|
||||
public AirPodsMax (Pod singlePod) {
|
||||
super(singlePod);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getDrawable () {
|
||||
return getPod().isConnected() ? R.drawable.podmax : R.drawable.podmax_disconnected;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getModel () {
|
||||
return MODEL_AIRPODS_MAX;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package com.dosse.airpods.pods.data;
|
||||
|
||||
import com.dosse.airpods.R;
|
||||
|
||||
public class AirPodsPro extends RegularPods {
|
||||
|
||||
public AirPodsPro (Pod leftPod, Pod rightPod, Pod casePod) {
|
||||
super(leftPod, rightPod, casePod);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getLeftDrawable () {
|
||||
return getPod(LEFT).isConnected() ? R.drawable.podpro : R.drawable.podpro_disconnected;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getRightDrawable () {
|
||||
return getPod(RIGHT).isConnected() ? R.drawable.podpro : R.drawable.podpro_disconnected;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getCaseDrawable () {
|
||||
return getPod(CASE).isConnected() ? R.drawable.podpro_case : R.drawable.podpro_case_disconnected;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getModel () {
|
||||
return MODEL_AIRPODS_PRO;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package com.dosse.airpods.pods.data;
|
||||
|
||||
import com.dosse.airpods.R;
|
||||
|
||||
public class BeatsFlex extends SinglePods {
|
||||
|
||||
public BeatsFlex (Pod singlePod) {
|
||||
super(singlePod);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getDrawable () {
|
||||
return getPod().isConnected() ? R.drawable.beatsflex : R.drawable.beatsflex_disconnected;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getModel () {
|
||||
return MODEL_BEATS_FLEX;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package com.dosse.airpods.pods.data;
|
||||
|
||||
import com.dosse.airpods.R;
|
||||
|
||||
public class BeatsSolo3 extends SinglePods {
|
||||
|
||||
public BeatsSolo3 (Pod singlePod) {
|
||||
super(singlePod);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getDrawable () {
|
||||
return getPod().isConnected() ? R.drawable.beatssolo3 : R.drawable.beatssolo3_disconnected;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getModel () {
|
||||
return MODEL_BEATS_SOLO_3;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package com.dosse.airpods.pods.data;
|
||||
|
||||
import com.dosse.airpods.R;
|
||||
|
||||
public class BeatsStudio3 extends SinglePods {
|
||||
|
||||
public BeatsStudio3 (Pod singlePod) {
|
||||
super(singlePod);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getDrawable () {
|
||||
return getPod().isConnected() ? R.drawable.beatsstudio3 : R.drawable.beatsstudio3_disconnected;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getModel () {
|
||||
return MODEL_BEATS_STUDIO_3;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package com.dosse.airpods.pods.data;
|
||||
|
||||
import com.dosse.airpods.R;
|
||||
|
||||
public class BeatsX extends SinglePods {
|
||||
|
||||
public BeatsX (Pod singlePod) {
|
||||
super(singlePod);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getDrawable () {
|
||||
return getPod().isConnected() ? R.drawable.beatsx : R.drawable.beatsx_disconnected;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getModel () {
|
||||
return MODEL_BEATS_X;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package com.dosse.airpods.pods.data;
|
||||
|
||||
public interface IPods {
|
||||
|
||||
String MODEL_AIRPODS_GEN1 = "airpods1";
|
||||
String MODEL_AIRPODS_GEN2 = "airpods2";
|
||||
String MODEL_AIRPODS_PRO = "airpodspro";
|
||||
String MODEL_AIRPODS_MAX = "airpodsmax";
|
||||
String MODEL_BEATS_X = "beatsx";
|
||||
String MODEL_BEATS_FLEX = "beatsflex";
|
||||
String MODEL_BEATS_SOLO_3 = "beatssolo3";
|
||||
String MODEL_BEATS_STUDIO_3 = "beatsstudio3";
|
||||
String MODEL_POWERBEATS_3 = "powerbeats3";
|
||||
String MODEL_POWERBEATS_PRO = "powerbeatspro";
|
||||
String MODEL_UNKNOWN = "unknown";
|
||||
|
||||
|
||||
String getModel ();
|
||||
|
||||
boolean isSingle ();
|
||||
|
||||
boolean isDisconnected ();
|
||||
|
||||
String parseStatusForLogger ();
|
||||
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package com.dosse.airpods.pods.data;
|
||||
|
||||
import android.view.View;
|
||||
|
||||
import com.dosse.airpods.R;
|
||||
|
||||
public class Pod {
|
||||
|
||||
public static final int DISCONNECTED_STATUS = 15;
|
||||
public static final int MAX_CONNECTED_STATUS = 10;
|
||||
public static final int LOW_BATTERY_STATUS = 1;
|
||||
|
||||
private final int status;
|
||||
private final boolean charging;
|
||||
private final boolean inEar;
|
||||
|
||||
public Pod (int status, boolean charging, boolean inEar) {
|
||||
this.status = status;
|
||||
this.charging = charging;
|
||||
this.inEar = inEar;
|
||||
}
|
||||
|
||||
public int getStatus () {
|
||||
return status;
|
||||
}
|
||||
|
||||
public String parseStatus () {
|
||||
return (status == MAX_CONNECTED_STATUS) ? "100%" : ((status < MAX_CONNECTED_STATUS) ? ((status * 10 + 5) + "%") : "");
|
||||
}
|
||||
|
||||
public boolean isCharging () {
|
||||
return charging;
|
||||
}
|
||||
|
||||
public boolean isInEar () {
|
||||
return inEar;
|
||||
}
|
||||
|
||||
public boolean isConnected () {
|
||||
return status <= MAX_CONNECTED_STATUS;
|
||||
}
|
||||
|
||||
public boolean isDisconnected () {
|
||||
return status == DISCONNECTED_STATUS;
|
||||
}
|
||||
|
||||
public boolean isLowBattery () {
|
||||
return status <= LOW_BATTERY_STATUS;
|
||||
}
|
||||
|
||||
public int inEarVisibility () {
|
||||
return inEar ? View.VISIBLE : View.INVISIBLE;
|
||||
}
|
||||
|
||||
public int batImgVisibility () {
|
||||
return (charging && isConnected() || isLowBattery()) ? View.VISIBLE : View.GONE;
|
||||
}
|
||||
|
||||
public int batImgSrcId () {
|
||||
return charging ? R.drawable.ic_battery_charging_full_green_24dp : R.drawable.ic_battery_alert_red_24dp;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package com.dosse.airpods.pods.data;
|
||||
|
||||
import com.dosse.airpods.R;
|
||||
|
||||
public class Powerbeats3 extends SinglePods {
|
||||
|
||||
public Powerbeats3 (Pod singlePod) {
|
||||
super(singlePod);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getDrawable () {
|
||||
return getPod().isConnected() ? R.drawable.powerbeats3 : R.drawable.powerbeats3_disconnected;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getModel () {
|
||||
return MODEL_POWERBEATS_3;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package com.dosse.airpods.pods.data;
|
||||
|
||||
import com.dosse.airpods.R;
|
||||
|
||||
public class PowerbeatsPro extends RegularPods {
|
||||
|
||||
public PowerbeatsPro (Pod leftPod, Pod rightPod, Pod casePod) {
|
||||
super(leftPod, rightPod, casePod);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getLeftDrawable () {
|
||||
return getPod(LEFT).isConnected() ? R.drawable.powerbeatspro : R.drawable.powerbeatspro_disconnected;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getRightDrawable () {
|
||||
return getPod(RIGHT).isConnected() ? R.drawable.powerbeatspro : R.drawable.powerbeatspro_disconnected;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getCaseDrawable () {
|
||||
return getPod(CASE).isConnected() ? R.drawable.powerbeatspro_case : R.drawable.powerbeatspro_case_disconnected;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getModel () {
|
||||
return MODEL_POWERBEATS_PRO;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package com.dosse.airpods.pods.data;
|
||||
|
||||
import com.dosse.airpods.R;
|
||||
|
||||
import java.util.Locale;
|
||||
|
||||
public class RegularPods implements IPods {
|
||||
|
||||
public static final int LEFT = 0, RIGHT = 1, CASE = 2;
|
||||
|
||||
private final Pod[] pods;
|
||||
|
||||
public RegularPods (Pod leftPod, Pod rightPod, Pod casePod) {
|
||||
this.pods = new Pod[] {leftPod, rightPod, casePod};
|
||||
}
|
||||
|
||||
public Pod getPod (int pos) {
|
||||
return pods[pos];
|
||||
}
|
||||
|
||||
public String getParsedStatus (int pos) {
|
||||
return pods[pos].parseStatus();
|
||||
}
|
||||
|
||||
public int getInEarVisibility (int pos) {
|
||||
return pods[pos].inEarVisibility();
|
||||
}
|
||||
|
||||
public int getBatImgVisibility (int pos) {
|
||||
return pods[pos].batImgVisibility();
|
||||
}
|
||||
|
||||
public int getBatImgSrcId (int pos) {
|
||||
return pods[pos].batImgSrcId();
|
||||
}
|
||||
|
||||
public int getLeftDrawable () {
|
||||
return getPod(LEFT).isConnected() ? R.drawable.pod : R.drawable.pod_disconnected;
|
||||
}
|
||||
|
||||
public int getRightDrawable () {
|
||||
return getPod(RIGHT).isConnected() ? R.drawable.pod : R.drawable.pod_disconnected;
|
||||
}
|
||||
|
||||
public int getCaseDrawable () {
|
||||
return getPod(CASE).isConnected() ? R.drawable.pod_case : R.drawable.pod_case_disconnected;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getModel () {
|
||||
return MODEL_UNKNOWN;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isSingle () {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isDisconnected () {
|
||||
return pods[LEFT].isDisconnected() &&
|
||||
pods[RIGHT].isDisconnected() &&
|
||||
pods[CASE].isDisconnected();
|
||||
}
|
||||
|
||||
@Override
|
||||
public String parseStatusForLogger () {
|
||||
return String.format(Locale.getDefault(), "Left: %d%s%s Right: %d%s%s Case: %d%s Model: %s",
|
||||
pods[LEFT].getStatus(), pods[LEFT].isCharging() ? "+" : "", pods[LEFT].isInEar() ? "$" : "",
|
||||
pods[RIGHT].getStatus(), pods[RIGHT].isCharging() ? "+" : "", pods[RIGHT].isInEar() ? "$" : "",
|
||||
pods[CASE].getStatus(), pods[CASE].isCharging() ? "+" : "", getModel());
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package com.dosse.airpods.pods.data;
|
||||
|
||||
import java.util.Locale;
|
||||
|
||||
public abstract class SinglePods implements IPods {
|
||||
|
||||
public abstract int getDrawable ();
|
||||
|
||||
private final Pod pod;
|
||||
|
||||
public SinglePods (Pod pod) {
|
||||
this.pod = pod;
|
||||
}
|
||||
|
||||
public Pod getPod () {
|
||||
return pod;
|
||||
}
|
||||
|
||||
public String getParsedStatus () {
|
||||
return pod.parseStatus();
|
||||
}
|
||||
|
||||
public int getBatImgVisibility () {
|
||||
return pod.batImgVisibility();
|
||||
}
|
||||
|
||||
public int getBatImgSrcId () {
|
||||
return pod.batImgSrcId();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isSingle () {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isDisconnected () {
|
||||
return pod.isDisconnected();
|
||||
}
|
||||
|
||||
@Override
|
||||
public String parseStatusForLogger () {
|
||||
return String.format(Locale.getDefault(), "Battery: %d%s Model: %s", pod.getStatus(), pod.isCharging() ? "+" : "", getModel());
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package com.dosse.airpods.receivers;
|
||||
|
||||
import android.bluetooth.BluetoothDevice;
|
||||
import android.bluetooth.BluetoothProfile;
|
||||
|
||||
public abstract class BluetoothListener implements BluetoothProfile.ServiceListener {
|
||||
|
||||
public abstract boolean onConnect (BluetoothDevice bluetoothDevice);
|
||||
|
||||
public abstract void onDisconnect ();
|
||||
|
||||
@Override
|
||||
public void onServiceConnected (int profile, BluetoothProfile bluetoothProfile) {
|
||||
if (profile == BluetoothProfile.HEADSET)
|
||||
for (BluetoothDevice device : bluetoothProfile.getConnectedDevices())
|
||||
if (onConnect(device))
|
||||
break;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onServiceDisconnected (int profile) {
|
||||
if (profile == BluetoothProfile.HEADSET)
|
||||
onDisconnect();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package com.dosse.airpods.receivers;
|
||||
|
||||
import android.bluetooth.BluetoothAdapter;
|
||||
import android.bluetooth.BluetoothDevice;
|
||||
import android.content.BroadcastReceiver;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.content.IntentFilter;
|
||||
|
||||
public abstract class BluetoothReceiver extends BroadcastReceiver {
|
||||
|
||||
public abstract void onStart ();
|
||||
|
||||
public abstract void onStop ();
|
||||
|
||||
public abstract void onConnect (BluetoothDevice bluetoothDevice);
|
||||
|
||||
public abstract void onDisconnect (BluetoothDevice bluetoothDevice);
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
public static IntentFilter buildFilter () {
|
||||
IntentFilter intentFilter = new IntentFilter();
|
||||
intentFilter.addAction("android.bluetooth.device.action.ACL_CONNECTED");
|
||||
intentFilter.addAction("android.bluetooth.device.action.ACL_DISCONNECTED");
|
||||
intentFilter.addAction("android.bluetooth.device.action.BOND_STATE_CHANGED");
|
||||
intentFilter.addAction("android.bluetooth.device.action.NAME_CHANGED");
|
||||
intentFilter.addAction("android.bluetooth.adapter.action.CONNECTION_STATE_CHANGED");
|
||||
intentFilter.addAction("android.bluetooth.adapter.action.STATE_CHANGED");
|
||||
intentFilter.addAction("android.bluetooth.headset.profile.action.CONNECTION_STATE_CHANGED");
|
||||
intentFilter.addAction("android.bluetooth.headset.action.VENDOR_SPECIFIC_HEADSET_EVENT");
|
||||
intentFilter.addAction("android.bluetooth.a2dp.profile.action.CONNECTION_STATE_CHANGED");
|
||||
intentFilter.addAction("android.bluetooth.a2dp.profile.action.PLAYING_STATE_CHANGED");
|
||||
intentFilter.addCategory("android.bluetooth.headset.intent.category.companyid.76");
|
||||
return intentFilter;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onReceive (Context context, Intent intent) {
|
||||
BluetoothDevice bluetoothDevice = intent.getParcelableExtra("android.bluetooth.device.extra.DEVICE");
|
||||
String action = intent.getAction();
|
||||
|
||||
if (BluetoothAdapter.ACTION_STATE_CHANGED.equals(action)) {
|
||||
int state = intent.getIntExtra(BluetoothAdapter.EXTRA_STATE, BluetoothAdapter.ERROR);
|
||||
|
||||
// Bluetooth turned off, stop scanner and remove notification.
|
||||
if (state == BluetoothAdapter.STATE_OFF || state == BluetoothAdapter.STATE_TURNING_OFF)
|
||||
onStop();
|
||||
|
||||
// Bluetooth turned on, start/restart scanner.
|
||||
if (state == BluetoothAdapter.STATE_ON)
|
||||
onStart();
|
||||
}
|
||||
|
||||
// Airpods filter
|
||||
if (bluetoothDevice != null && action != null && !action.isEmpty()) {
|
||||
// Airpods connected, show notification.
|
||||
if (BluetoothDevice.ACTION_ACL_CONNECTED.equals(action))
|
||||
onConnect(bluetoothDevice);
|
||||
|
||||
// Airpods disconnected, remove notification but leave the scanner going.
|
||||
if (BluetoothDevice.ACTION_ACL_DISCONNECTED.equals(action) || BluetoothDevice.ACTION_ACL_DISCONNECT_REQUESTED.equals(action))
|
||||
onDisconnect(bluetoothDevice);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package com.dosse.airpods.receivers;
|
||||
|
||||
import android.content.BroadcastReceiver;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.content.IntentFilter;
|
||||
|
||||
public abstract class ScreenReceiver extends BroadcastReceiver {
|
||||
|
||||
public abstract void onStart ();
|
||||
|
||||
public abstract void onStop ();
|
||||
|
||||
public static IntentFilter buildFilter () {
|
||||
IntentFilter screenIntentFilter = new IntentFilter();
|
||||
screenIntentFilter.addAction(Intent.ACTION_SCREEN_ON);
|
||||
screenIntentFilter.addAction(Intent.ACTION_SCREEN_OFF);
|
||||
return screenIntentFilter;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onReceive (Context context, Intent intent) {
|
||||
switch (intent.getAction()) {
|
||||
case Intent.ACTION_SCREEN_OFF: onStop();
|
||||
break;
|
||||
case Intent.ACTION_SCREEN_ON: onStart();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package com.dosse.airpods.receivers;
|
||||
|
||||
import android.content.BroadcastReceiver;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.os.Build;
|
||||
|
||||
import com.dosse.airpods.pods.PodsService;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* A simple startup class that starts the service when the device is booted, or after an update
|
||||
*/
|
||||
public class StartupReceiver extends BroadcastReceiver {
|
||||
|
||||
@Override
|
||||
public void onReceive (Context context, Intent intent) {
|
||||
switch (Objects.requireNonNull(intent.getAction())) {
|
||||
case Intent.ACTION_MY_PACKAGE_REPLACED:
|
||||
case Intent.ACTION_BOOT_COMPLETED:
|
||||
startPodsService(context);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
public static void startPodsService (Context context) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R)
|
||||
context.startForegroundService(new Intent(context, PodsService.class));
|
||||
else
|
||||
context.startService(new Intent(context, PodsService.class));
|
||||
}
|
||||
|
||||
public static void restartPodsService (Context context) {
|
||||
context.stopService(new Intent(context, PodsService.class));
|
||||
try {
|
||||
Thread.sleep(500);
|
||||
} catch (Throwable ignored) {
|
||||
}
|
||||
startPodsService(context);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package com.dosse.airpods.ui;
|
||||
|
||||
import android.content.Intent;
|
||||
import android.net.Uri;
|
||||
import android.os.Bundle;
|
||||
import android.webkit.WebView;
|
||||
import android.widget.Toast;
|
||||
import androidx.appcompat.app.AppCompatActivity;
|
||||
|
||||
import com.dosse.airpods.R;
|
||||
|
||||
public class AboutActivity extends AppCompatActivity {
|
||||
|
||||
@Override
|
||||
protected void onCreate (Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
setContentView(R.layout.activity_about);
|
||||
|
||||
// --------------- LEGAL DANGER ZONE! --------------- //
|
||||
|
||||
/*
|
||||
* The following actions are a violation of the GNU GPLv3 License:
|
||||
* - Removing, hiding or altering the license or the code that displays it
|
||||
* - Removing or changing the link to the original source code or the code that displays it
|
||||
* - Removing or changing the donation link of the original project or the code that displays it
|
||||
*
|
||||
* If you're forking the application, it MUST be made clear that your version is a fork,
|
||||
* who the original author is, and all references to the original project and license must not be removed.
|
||||
* You are free to add links to your project, donations, whatever to this activity.
|
||||
*
|
||||
* I am constantly monitoring all major Android app stores for violations. You have been warned.
|
||||
*/
|
||||
|
||||
((WebView)(findViewById(R.id.license))).loadUrl("file:///android_asset/license.html");
|
||||
|
||||
findViewById(R.id.website).setOnClickListener(v -> startActivity(new Intent(Intent.ACTION_VIEW, Uri.parse(getString(R.string.website_url)))));
|
||||
findViewById(R.id.github).setOnClickListener(v -> startActivity(new Intent(Intent.ACTION_VIEW, Uri.parse(getString(R.string.github_url)))));
|
||||
findViewById(R.id.donate).setOnClickListener(v -> {
|
||||
startActivity(new Intent(Intent.ACTION_VIEW, Uri.parse(getString(R.string.donate_url))));
|
||||
Toast.makeText(AboutActivity.this, "❤️", Toast.LENGTH_SHORT).show();
|
||||
});
|
||||
|
||||
// ------------ END OF LEGAL DANGER ZONE ------------ //
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
package com.dosse.airpods.ui;
|
||||
|
||||
import android.Manifest;
|
||||
import android.annotation.SuppressLint;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.content.pm.PackageManager;
|
||||
import android.net.Uri;
|
||||
import android.os.Build;
|
||||
import android.os.Bundle;
|
||||
import android.provider.Settings;
|
||||
import android.widget.Button;
|
||||
import android.widget.TextView;
|
||||
import android.widget.Toast;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.appcompat.app.AppCompatActivity;
|
||||
|
||||
import com.dosse.airpods.R;
|
||||
import com.dosse.airpods.utils.PermissionUtils;
|
||||
|
||||
import java.util.Locale;
|
||||
import java.util.Timer;
|
||||
import java.util.TimerTask;
|
||||
|
||||
public class IntroActivity extends AppCompatActivity {
|
||||
|
||||
private Timer timer;
|
||||
private TextView msg;
|
||||
private Button btn;
|
||||
|
||||
private static final int STEP_PERMISSION_BLUETOOTH = 1, STEP_PERMISSION_BATTERY_OPTIMIZATION = 2, STEP_PERMISSION_LOCATION = 3, STEP_PERMISSION_BACKGROUND_LOCATION = 4;
|
||||
private static final int BLUETOOTH_REQUEST_CODE = 101, LOCATION_REQUEST_CODE = 102, BACKGROUND_LOCATION_REQUEST_CODE = 103;
|
||||
|
||||
@Override
|
||||
protected void onCreate (Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
setContentView(R.layout.activity_intro);
|
||||
|
||||
msg = findViewById(R.id.permsMsg);
|
||||
btn = findViewById(R.id.permsBtn);
|
||||
|
||||
// Wait for permissions to be granted.
|
||||
// When they are granted, go to MainActivity.
|
||||
timer = new Timer();
|
||||
timer.scheduleAtFixedRate(new TimerTask() {
|
||||
@Override
|
||||
public void run () {
|
||||
initScreen();
|
||||
|
||||
if (PermissionUtils.checkAllPermissions(IntroActivity.this)) {
|
||||
timer.cancel();
|
||||
startActivity(new Intent(IntroActivity.this, MainActivity.class));
|
||||
finish();
|
||||
}
|
||||
}
|
||||
}, 0, 100);
|
||||
|
||||
}
|
||||
|
||||
// Activity destroyed (or screen rotated). destroy the timer too
|
||||
@Override
|
||||
protected void onDestroy () {
|
||||
super.onDestroy();
|
||||
if (timer != null)
|
||||
timer.cancel();
|
||||
}
|
||||
|
||||
private int getPermissionState () {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) if (!PermissionUtils.getBluetoothPermissions(this))
|
||||
return STEP_PERMISSION_BLUETOOTH;
|
||||
if (!PermissionUtils.getBatteryOptimizationsPermission(this))
|
||||
return STEP_PERMISSION_BATTERY_OPTIMIZATION;
|
||||
if (!PermissionUtils.getFineLocationPermission(this))
|
||||
return STEP_PERMISSION_LOCATION;
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) if (!PermissionUtils.getBackgroundLocationPermission(this))
|
||||
return STEP_PERMISSION_BACKGROUND_LOCATION;
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
@SuppressLint("BatteryLife")
|
||||
private void initScreen () {
|
||||
int currentStep = getPermissionState() - ((Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) ? 0 : 1);
|
||||
int numOfSteps = (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) ? ((Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) ? 4 : 3) : 2;
|
||||
|
||||
runOnUiThread(() -> {
|
||||
switch (getPermissionState()) {
|
||||
case STEP_PERMISSION_BLUETOOTH:
|
||||
msg.setText(String.format(Locale.getDefault(), "%s %d/%d: %s", getString(R.string.intro_step), currentStep, numOfSteps, getString(R.string.intro_bt_perm)));
|
||||
btn.setOnClickListener(view -> {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S)
|
||||
requestPermissions(new String[] {Manifest.permission.BLUETOOTH_SCAN, Manifest.permission.BLUETOOTH_CONNECT}, BLUETOOTH_REQUEST_CODE);
|
||||
});
|
||||
break;
|
||||
case STEP_PERMISSION_BATTERY_OPTIMIZATION:
|
||||
msg.setText(String.format(Locale.getDefault(), "%s %d/%d: %s", getString(R.string.intro_step), currentStep, numOfSteps, getString(R.string.intro_bat_perm)));
|
||||
btn.setOnClickListener(view -> {
|
||||
Intent intent = new Intent();
|
||||
getSystemService(Context.POWER_SERVICE);
|
||||
intent.setAction(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS);
|
||||
intent.setData(Uri.parse("package:" + getPackageName()));
|
||||
startActivity(intent);
|
||||
});
|
||||
break;
|
||||
case STEP_PERMISSION_LOCATION:
|
||||
msg.setText(String.format(Locale.getDefault(), "%s %d/%d: %s", getString(R.string.intro_step), currentStep, numOfSteps, getString(R.string.intro_loc1_perm)));
|
||||
btn.setOnClickListener(view -> requestPermissions(new String[] {Manifest.permission.ACCESS_FINE_LOCATION}, LOCATION_REQUEST_CODE)); // Location (for BLE)
|
||||
break;
|
||||
case STEP_PERMISSION_BACKGROUND_LOCATION:
|
||||
msg.setText(String.format(Locale.getDefault(), "%s %d/%d: %s", getString(R.string.intro_step), currentStep, numOfSteps, getString(R.string.intro_loc2_perm)));
|
||||
btn.setOnClickListener(view -> {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) requestPermissions(new String[] {Manifest.permission.ACCESS_BACKGROUND_LOCATION}, BACKGROUND_LOCATION_REQUEST_CODE);
|
||||
});
|
||||
break;
|
||||
}
|
||||
btn.setText(String.format(Locale.getDefault(), "%s (%d/%d)", getString(R.string.intro_allow), currentStep, numOfSteps));
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onRequestPermissionsResult (int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) {
|
||||
super.onRequestPermissionsResult(requestCode, permissions, grantResults);
|
||||
switch (requestCode) {
|
||||
case BLUETOOTH_REQUEST_CODE:
|
||||
if (!hasAllPermissionsGranted(grantResults)) Toast.makeText(this, getString(R.string.msg_bt_perm), Toast.LENGTH_LONG).show();
|
||||
break;
|
||||
case LOCATION_REQUEST_CODE:
|
||||
if (!hasAllPermissionsGranted(grantResults)) Toast.makeText(this, getString(R.string.msg_loc1_perm), Toast.LENGTH_LONG).show();
|
||||
break;
|
||||
case BACKGROUND_LOCATION_REQUEST_CODE:
|
||||
if (!hasAllPermissionsGranted(grantResults)) Toast.makeText(this, getString(R.string.msg_loc2_perm), Toast.LENGTH_LONG).show();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressWarnings("BooleanMethodIsAlwaysInverted")
|
||||
public boolean hasAllPermissionsGranted (@NonNull int[] grantResults) {
|
||||
for (int grantResult : grantResults)
|
||||
if (grantResult == PackageManager.PERMISSION_DENIED)
|
||||
return false;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package com.dosse.airpods.ui;
|
||||
|
||||
import android.bluetooth.BluetoothAdapter;
|
||||
import android.bluetooth.BluetoothManager;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.content.pm.PackageManager;
|
||||
import android.os.Bundle;
|
||||
import android.view.Menu;
|
||||
import android.view.MenuItem;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.appcompat.app.AppCompatActivity;
|
||||
|
||||
import com.dosse.airpods.R;
|
||||
import com.dosse.airpods.receivers.StartupReceiver;
|
||||
import com.dosse.airpods.utils.MIUIWarning;
|
||||
import com.dosse.airpods.utils.PermissionUtils;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
public class MainActivity extends AppCompatActivity {
|
||||
|
||||
@Override
|
||||
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)Objects.requireNonNull(getSystemService(Context.BLUETOOTH_SERVICE))).getAdapter();
|
||||
if (btAdapter == null || (btAdapter.isEnabled() && btAdapter.getBluetoothLeScanner() == null) || (!getPackageManager().hasSystemFeature(PackageManager.FEATURE_BLUETOOTH_LE))) {
|
||||
startActivity(new Intent(MainActivity.this, NoBTActivity.class));
|
||||
finish();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!PermissionUtils.checkAllPermissions(this)) {
|
||||
startActivity(new Intent(MainActivity.this, IntroActivity.class));
|
||||
finish();
|
||||
} else {
|
||||
StartupReceiver.startPodsService(getApplicationContext());
|
||||
//Warn MIUI users that their rom has known issues
|
||||
MIUIWarning.show(this);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onCreateOptionsMenu (Menu menu) {
|
||||
getMenuInflater().inflate(R.menu.menu_main, menu);
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onOptionsItemSelected (@NonNull MenuItem item) {
|
||||
if (item.getItemId() == R.id.menu_ab_settings) {
|
||||
startActivity(new Intent(this, SettingsActivity.class)); // Settings icon clicked
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.dosse.airpods.ui;
|
||||
|
||||
import android.os.Bundle;
|
||||
import androidx.appcompat.app.AppCompatActivity;
|
||||
|
||||
import com.dosse.airpods.R;
|
||||
|
||||
public class NoBTActivity extends AppCompatActivity {
|
||||
|
||||
@Override
|
||||
protected void onCreate (Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
setContentView(R.layout.activity_no_bt);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package com.dosse.airpods.ui;
|
||||
|
||||
import android.os.Bundle;
|
||||
import android.widget.Toast;
|
||||
import androidx.appcompat.app.AppCompatActivity;
|
||||
import androidx.preference.Preference;
|
||||
import androidx.preference.PreferenceFragmentCompat;
|
||||
|
||||
import com.dosse.airpods.BuildConfig;
|
||||
import com.dosse.airpods.R;
|
||||
import com.dosse.airpods.receivers.StartupReceiver;
|
||||
|
||||
public class SettingsActivity extends AppCompatActivity {
|
||||
|
||||
@Override
|
||||
protected void onCreate (Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
setContentView(R.layout.activity_settings);
|
||||
getSupportFragmentManager()
|
||||
.beginTransaction()
|
||||
.replace(R.id.settings_container, new SettingsFragment())
|
||||
.commit();
|
||||
}
|
||||
|
||||
public static class SettingsFragment extends PreferenceFragmentCompat {
|
||||
|
||||
@Override
|
||||
public void onCreatePreferences (Bundle savedInstanceState, String rootKey) {
|
||||
setPreferencesFromResource(R.xml.preference_screen, rootKey);
|
||||
|
||||
getPreference("batterySaver").setOnPreferenceClickListener(preference -> {
|
||||
StartupReceiver.restartPodsService(requireContext());
|
||||
return true;
|
||||
});
|
||||
|
||||
getPreference("restartService").setOnPreferenceClickListener(preference -> {
|
||||
StartupReceiver.restartPodsService(requireContext());
|
||||
return true;
|
||||
});
|
||||
|
||||
getPreference("about").setSummary(String.format("%s v%s", getString(R.string.app_name), BuildConfig.VERSION_NAME));
|
||||
|
||||
getPreference("donate").setOnPreferenceClickListener(preference -> {
|
||||
Toast.makeText(requireContext(), "❤️", Toast.LENGTH_SHORT).show();
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
private Preference getPreference (String key) {
|
||||
return getPreferenceManager().findPreference(key);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package com.dosse.airpods.utils;
|
||||
|
||||
import android.util.Log;
|
||||
|
||||
import com.dosse.airpods.BuildConfig;
|
||||
|
||||
public class Logger {
|
||||
|
||||
// Log is only displayed if this is a debug build, not release
|
||||
private static final boolean ENABLE_LOGGING = BuildConfig.DEBUG;
|
||||
public static final String TAG = "AirPods";
|
||||
|
||||
public static void debug (String msg) {
|
||||
if (ENABLE_LOGGING) Log.d(TAG, msg);
|
||||
}
|
||||
|
||||
public static void error (Throwable t) {
|
||||
if (ENABLE_LOGGING) Log.e(TAG, "ERROR", t);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package com.dosse.airpods.utils;
|
||||
|
||||
import android.annotation.SuppressLint;
|
||||
import android.content.Context;
|
||||
import androidx.appcompat.app.AlertDialog;
|
||||
|
||||
import com.dosse.airpods.R;
|
||||
|
||||
public class MIUIWarning {
|
||||
|
||||
public static void show (Context context) {
|
||||
try {
|
||||
@SuppressLint("PrivateApi") Class<?> c = Class.forName("android.os.SystemProperties");
|
||||
String miuiVersion = (String)c.getMethod("get", String.class).invoke(c, "ro.miui.ui.version.code");
|
||||
if (miuiVersion != null && !miuiVersion.isEmpty()) {
|
||||
try {
|
||||
context.getApplicationContext().openFileInput("miuiwarn").close();
|
||||
} catch (Throwable ignored) {
|
||||
AlertDialog.Builder builder = new AlertDialog.Builder(context);
|
||||
builder.setTitle(R.string.miui_warning);
|
||||
builder.setMessage(R.string.miui_warning_desc);
|
||||
builder.setNeutralButton(R.string.miui_warning_continue, (dialog, which) -> dialog.dismiss());
|
||||
builder.setOnDismissListener(dialog -> {
|
||||
try {
|
||||
context.getApplicationContext().openFileOutput("miuiwarn", Context.MODE_PRIVATE).close();
|
||||
} catch (Throwable ignored2) {
|
||||
}
|
||||
});
|
||||
builder.show();
|
||||
}
|
||||
}
|
||||
} catch (Throwable ignored) {
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package com.dosse.airpods.utils;
|
||||
|
||||
import android.Manifest;
|
||||
import android.content.Context;
|
||||
import android.content.pm.PackageManager;
|
||||
import android.os.Build;
|
||||
import android.os.PowerManager;
|
||||
import androidx.annotation.RequiresApi;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
import static androidx.core.content.ContextCompat.checkSelfPermission;
|
||||
|
||||
public class PermissionUtils {
|
||||
|
||||
public static boolean getBatteryOptimizationsPermission (Context context) {
|
||||
try {
|
||||
return Objects.requireNonNull(context.getSystemService(PowerManager.class)).isIgnoringBatteryOptimizations(context.getPackageName());
|
||||
} catch (Throwable ignored) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@RequiresApi(api = Build.VERSION_CODES.S)
|
||||
public static boolean getBluetoothPermissions (Context context) {
|
||||
return checkSelfPermission(context, Manifest.permission.BLUETOOTH_SCAN) == PackageManager.PERMISSION_GRANTED
|
||||
&& checkSelfPermission(context, Manifest.permission.BLUETOOTH_CONNECT) == PackageManager.PERMISSION_GRANTED;
|
||||
}
|
||||
|
||||
public static boolean getFineLocationPermission (Context context) {
|
||||
return checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED;
|
||||
}
|
||||
|
||||
@RequiresApi(api = Build.VERSION_CODES.Q)
|
||||
public static boolean getBackgroundLocationPermission (Context context) {
|
||||
return checkSelfPermission(context, Manifest.permission.ACCESS_BACKGROUND_LOCATION) == PackageManager.PERMISSION_GRANTED;
|
||||
}
|
||||
|
||||
public static boolean getLocationPermissions (Context context) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R)
|
||||
return getFineLocationPermission(context) && getBackgroundLocationPermission(context);
|
||||
else
|
||||
return getFineLocationPermission(context);
|
||||
}
|
||||
|
||||
public static boolean checkAllPermissions (Context context) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S)
|
||||
return getBatteryOptimizationsPermission(context) && getLocationPermissions(context) && getBluetoothPermissions(context);
|
||||
else
|
||||
return getBatteryOptimizationsPermission(context) && getLocationPermissions(context);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.dosse.airpods.utils;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.SharedPreferences;
|
||||
import androidx.preference.PreferenceManager;
|
||||
|
||||
public class SharedPreferencesUtils {
|
||||
|
||||
public static boolean isSavingBattery (Context context) {
|
||||
SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context);
|
||||
return prefs.getBoolean("batterySaver", false);
|
||||
}
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user