add persistent notification for battery; bug fixes

This commit is contained in:
Kavish Devar
2024-12-05 10:02:19 +05:30
parent 7a246b3800
commit 0c1f9464ad
13 changed files with 536 additions and 230 deletions

View File

@@ -14,7 +14,7 @@ android {
minSdk = 28 minSdk = 28
targetSdk = 35 targetSdk = 35
versionCode = 1 versionCode = 1
versionName = "1.0" versionName = "0.0.2-beta"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
} }

View File

@@ -19,6 +19,7 @@ import android.os.Build
import android.os.IBinder import android.os.IBinder
import android.os.ParcelUuid import android.os.ParcelUuid
import android.util.Log import android.util.Log
import android.widget.RemoteViews
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@@ -36,12 +37,19 @@ class AirPodsService: Service() {
return LocalBinder() return LocalBinder()
} }
var popupShown = false
fun showPopup(service: Service, name: String) { fun showPopup(service: Service, name: String) {
if (popupShown) {
return
}
val window = Window(service.applicationContext) val window = Window(service.applicationContext)
window.open(name, batteryNotification) window.open(name, batteryNotification)
popupShown = true
} }
private object Receiver: BroadcastReceiver() { @Suppress("ClassName")
private object bluetoothReceiver: BroadcastReceiver() {
@SuppressLint("NewApi", "MissingPermission") @SuppressLint("NewApi", "MissingPermission")
override fun onReceive(context: Context?, intent: Intent) { override fun onReceive(context: Context?, intent: Intent) {
val bluetoothDevice = val bluetoothDevice =
@@ -50,11 +58,10 @@ class AirPodsService: Service() {
val context = context?.applicationContext val context = context?.applicationContext
val name = context?.getSharedPreferences("settings", MODE_PRIVATE)?.getString("name", bluetoothDevice?.name) val name = context?.getSharedPreferences("settings", MODE_PRIVATE)?.getString("name", bluetoothDevice?.name)
if (bluetoothDevice != null && action != null && !action.isEmpty()) { if (bluetoothDevice != null && action != null && !action.isEmpty()) {
Log.d("BluetoothReceiver", "Received broadcast") Log.d("AirPodsService", "Received bluetooth connection broadcast")
if (BluetoothDevice.ACTION_ACL_CONNECTED == action) { if (BluetoothDevice.ACTION_ACL_CONNECTED == action) {
val uuid = ParcelUuid.fromString("74ec2172-0bad-4d01-8f77-997b2be0722a") val uuid = ParcelUuid.fromString("74ec2172-0bad-4d01-8f77-997b2be0722a")
if (bluetoothDevice.uuids.contains(uuid)) { if (bluetoothDevice.uuids.contains(uuid)) {
Log.d("AirPodsService", "Service started")
val intent = Intent(AirPodsNotifications.AIRPODS_CONNECTION_DETECTED) val intent = Intent(AirPodsNotifications.AIRPODS_CONNECTION_DETECTED)
intent.putExtra("name", name) intent.putExtra("name", name)
intent.putExtra("device", bluetoothDevice) intent.putExtra("device", bluetoothDevice)
@@ -66,7 +73,6 @@ class AirPodsService: Service() {
if (BluetoothDevice.ACTION_ACL_DISCONNECTED == action if (BluetoothDevice.ACTION_ACL_DISCONNECTED == action
|| BluetoothDevice.ACTION_ACL_DISCONNECT_REQUESTED == action || BluetoothDevice.ACTION_ACL_DISCONNECT_REQUESTED == action
) { ) {
Log.d("AirPodsService", "Closed Socket")
context?.sendBroadcast( context?.sendBroadcast(
Intent(AirPodsNotifications.AIRPODS_DISCONNECTED) Intent(AirPodsNotifications.AIRPODS_DISCONNECTED)
) )
@@ -78,50 +84,135 @@ class AirPodsService: Service() {
var isConnected = false var isConnected = false
var device: BluetoothDevice? = null var device: BluetoothDevice? = null
private lateinit var earReceiver: BroadcastReceiver
fun startForegroundNotification() { fun startForegroundNotification() {
val notificationChannel = NotificationChannel( val notificationChannel = NotificationChannel(
"airpods", "background_service_status",
"AirPods", "Background Service Status",
NotificationManager.IMPORTANCE_DEFAULT NotificationManager.IMPORTANCE_LOW
) )
val notificationManager = getSystemService(NotificationManager::class.java) val notificationManager = getSystemService(NotificationManager::class.java)
notificationManager.createNotificationChannel(notificationChannel) notificationManager.createNotificationChannel(notificationChannel)
val notification = NotificationCompat.Builder(this, "background_service_status")
val notification = NotificationCompat.Builder(this, "airpods") .setSmallIcon(R.drawable.airpods)
.setSmallIcon(R.drawable.ic_launcher_foreground) .setContentTitle("AirPods Service")
.setContentTitle("AirPods Service Running") .setContentText("Service is running in the background")
.setContentText("AirPods service is running in the background.")
.setCategory(Notification.CATEGORY_SERVICE) .setCategory(Notification.CATEGORY_SERVICE)
.setPriority(NotificationCompat.PRIORITY_LOW) .setPriority(NotificationCompat.PRIORITY_LOW)
.build() .build()
startForeground(2, notification) try {
startForeground(1, notification)
}
catch (e: Exception) {
e.printStackTrace()
}
} }
fun updateNotificationContent(connected: Boolean, airpodsName: String? = null, batteryList: List<Battery>? = null) {
val notificationManager = getSystemService(NotificationManager::class.java)
val textColor = this.getSharedPreferences("settings", MODE_PRIVATE).getLong("textColor", 0)
var updatedNotification: Notification? = null
if (connected) {
val collapsedRemoteViews = RemoteViews(packageName, R.layout.notification)
val expandedRemoteViews = RemoteViews(packageName, R.layout.notification_expanded)
collapsedRemoteViews.setTextColor(R.id.notification_title, textColor.toInt())
collapsedRemoteViews.setTextViewText(R.id.notification_title, "Connected to $airpodsName")
expandedRemoteViews.setTextViewText(
R.id.notification_title,
"Connected to $airpodsName"
)
expandedRemoteViews.setTextViewText(
R.id.left_battery_notification,
batteryList?.find { it.component == BatteryComponent.LEFT }?.let {
if (it.status != BatteryStatus.DISCONNECTED) {
"Left ${if (it.status == BatteryStatus.CHARGING) "⚡" else ""} ${it.level}%"
} else {
""
}
} ?: "")
expandedRemoteViews.setTextViewText(
R.id.right_battery_notification,
batteryList?.find { it.component == BatteryComponent.RIGHT }?.let {
if (it.status != BatteryStatus.DISCONNECTED) {
"Right ${if (it.status == BatteryStatus.CHARGING) "⚡" else ""} ${it.level}%"
} else {
""
}
} ?: "")
expandedRemoteViews.setTextViewText(
R.id.case_battery_notification,
batteryList?.find { it.component == BatteryComponent.CASE }?.let {
if (it.status != BatteryStatus.DISCONNECTED) {
"Case ${if (it.status == BatteryStatus.CHARGING) "⚡" else ""} ${it.level}%"
} else {
""
}
} ?: "")
expandedRemoteViews.setTextColor(R.id.notification_title, textColor.toInt())
expandedRemoteViews.setTextColor(R.id.left_battery_notification, textColor.toInt())
expandedRemoteViews.setTextColor(R.id.right_battery_notification, textColor.toInt())
expandedRemoteViews.setTextColor(R.id.case_battery_notification, textColor.toInt())
updatedNotification = NotificationCompat.Builder(this, "background_service_status")
.setSmallIcon(R.drawable.airpods)
.setStyle(NotificationCompat.DecoratedCustomViewStyle())
.setCustomContentView(collapsedRemoteViews)
.setCustomBigContentView(expandedRemoteViews)
.setPriority(NotificationCompat.PRIORITY_LOW)
.setCategory(Notification.CATEGORY_SERVICE)
.setOngoing(true)
.build()
} else {
updatedNotification = NotificationCompat.Builder(this, "background_service_status")
.setSmallIcon(R.drawable.airpods)
.setContentTitle("AirPods Service")
.setContentText("Service is running in the background!")
.setCategory(Notification.CATEGORY_SERVICE)
.setPriority(NotificationCompat.PRIORITY_LOW)
.build()
}
// Notify the NotificationManager with the same ID
notificationManager.notify(1, updatedNotification)
}
private lateinit var connectionReceiver: BroadcastReceiver
private lateinit var disconnectionReceiver: BroadcastReceiver
@SuppressLint("InlinedApi", "MissingPermission") @SuppressLint("InlinedApi", "MissingPermission")
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
Log.d("AirPodsService", "Service started") Log.d("AirPodsService", "Service started")
startForegroundNotification() startForegroundNotification()
registerReceiver(Receiver, BluetoothReceiver.buildFilter(), RECEIVER_EXPORTED) registerReceiver(bluetoothReceiver, BluetoothReceiver.buildFilter(), RECEIVER_EXPORTED)
registerReceiver(object: BroadcastReceiver() { connectionReceiver = object: BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) { override fun onReceive(context: Context?, intent: Intent?) {
val name = this@AirPodsService.getSharedPreferences("settings", MODE_PRIVATE).getString("name", device?.name) if (intent?.action == AirPodsNotifications.AIRPODS_CONNECTION_DETECTED) {
device = intent?.getParcelableExtra("device", BluetoothDevice::class.java)!! val name = this@AirPodsService.getSharedPreferences("settings", MODE_PRIVATE)
showPopup(this@AirPodsService, name.toString()) .getString("name", device?.name)
connectToSocket(device!!) Log.d("AirPodsService", "$name connected")
sendBroadcast(Intent(AirPodsNotifications.AIRPODS_CONNECTED).apply { device = intent.getParcelableExtra("device", BluetoothDevice::class.java)!!
putExtra("device", device) showPopup(this@AirPodsService, name.toString())
}) connectToSocket(device!!)
updateNotificationContent(true, name.toString(), batteryNotification.getBattery())
}
else if (intent?.action == AirPodsNotifications.AIRPODS_DISCONNECTED) {
device = null
isConnected = false
popupShown = false
updateNotificationContent(false)
}
} }
}, IntentFilter(AirPodsNotifications.AIRPODS_CONNECTION_DETECTED), RECEIVER_EXPORTED) }
registerReceiver(object: BroadcastReceiver() { val intentFilter = IntentFilter().apply {
override fun onReceive(context: Context?, intent: Intent?) { addAction(AirPodsNotifications.AIRPODS_CONNECTION_DETECTED)
device = null addAction(AirPodsNotifications.AIRPODS_DISCONNECTED)
isConnected = false }
} registerReceiver(connectionReceiver, intentFilter, RECEIVER_EXPORTED)
}, IntentFilter(AirPodsNotifications.AIRPODS_DISCONNECTED), RECEIVER_EXPORTED)
val bluetoothAdapter = getSystemService(BluetoothManager::class.java).adapter val bluetoothAdapter = getSystemService(BluetoothManager::class.java).adapter
bluetoothAdapter.bondedDevices.forEach { device -> bluetoothAdapter.bondedDevices.forEach { device ->
@@ -200,9 +291,8 @@ class AirPodsService: Service() {
delay(500) delay(500)
it.outputStream.write(Enums.REQUEST_NOTIFICATIONS.value) it.outputStream.write(Enums.REQUEST_NOTIFICATIONS.value)
it.outputStream.flush() it.outputStream.flush()
Log.d("AirPodsService","This should run first")
} }
Log.d("AirPodsService","This should run later")
sendBroadcast( sendBroadcast(
Intent(AirPodsNotifications.AIRPODS_CONNECTED) Intent(AirPodsNotifications.AIRPODS_CONNECTED)
.putExtra("device", device) .putExtra("device", device)
@@ -243,7 +333,7 @@ class AirPodsService: Service() {
}) })
Log.d("AirPods Parser", "Ear Detection: ${earDetectionNotification.status[0]} ${earDetectionNotification.status[1]}") Log.d("AirPods Parser", "Ear Detection: ${earDetectionNotification.status[0]} ${earDetectionNotification.status[1]}")
var justEnabledA2dp = false var justEnabledA2dp = false
val earReceiver = object : BroadcastReceiver() { earReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) { override fun onReceive(context: Context, intent: Intent) {
val data = intent.getByteArrayExtra("data") val data = intent.getByteArrayExtra("data")
if (data != null && earDetectionEnabled) { if (data != null && earDetectionEnabled) {
@@ -325,17 +415,16 @@ class AirPodsService: Service() {
sendBroadcast(Intent(AirPodsNotifications.BATTERY_DATA).apply { sendBroadcast(Intent(AirPodsNotifications.BATTERY_DATA).apply {
putParcelableArrayListExtra("data", ArrayList(batteryNotification.getBattery())) putParcelableArrayListExtra("data", ArrayList(batteryNotification.getBattery()))
}) })
updateNotificationContent(true, this@AirPodsService.getSharedPreferences("settings", MODE_PRIVATE).getString("name", device.name), batteryNotification.getBattery())
for (battery in batteryNotification.getBattery()) { for (battery in batteryNotification.getBattery()) {
Log.d("AirPods Parser", "${battery.getComponentName()}: ${battery.getStatusName()} at ${battery.level}% ") Log.d("AirPods Parser", "${battery.getComponentName()}: ${battery.getStatusName()} at ${battery.level}% ")
} }
// if both are charging, disconnect audio profiles
if (batteryNotification.getBattery()[0].status == 1 && batteryNotification.getBattery()[1].status == 1) { if (batteryNotification.getBattery()[0].status == 1 && batteryNotification.getBattery()[1].status == 1) {
disconnectAudio(this@AirPodsService, device) disconnectAudio(this@AirPodsService, device)
} }
else { else {
connectAudio(this@AirPodsService, device) connectAudio(this@AirPodsService, device)
} }
// updatePodsStatus(device!!, batteryNotification.getBattery())
} }
else if (conversationAwarenessNotification.isConversationalAwarenessData(data)) { else if (conversationAwarenessNotification.isConversationalAwarenessData(data)) {
conversationAwarenessNotification.setData(data) conversationAwarenessNotification.setData(data)
@@ -357,7 +446,6 @@ class AirPodsService: Service() {
} }
Log.d("AirPods Service", "Socket closed") Log.d("AirPods Service", "Socket closed")
isConnected = false isConnected = false
this@AirPodsService.stopForeground(STOP_FOREGROUND_REMOVE)
socket.close() socket.close()
sendBroadcast(Intent(AirPodsNotifications.AIRPODS_DISCONNECTED)) sendBroadcast(Intent(AirPodsNotifications.AIRPODS_DISCONNECTED))
} }
@@ -471,22 +559,6 @@ class AirPodsService: Service() {
return ancNotification.status return ancNotification.status
} }
private fun createNotification(): Notification {
val channelId = "battery"
val notificationBuilder = NotificationCompat.Builder(this, channelId)
.setSmallIcon(R.drawable.pro_2_buds)
.setContentTitle("AirPods Connected")
.setOngoing(true)
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
val channel =
NotificationChannel(channelId, "Battery Notification", NotificationManager.IMPORTANCE_LOW)
val notificationManager = getSystemService(NotificationManager::class.java)
notificationManager.createNotificationChannel(channel)
return notificationBuilder.build()
}
fun disconnectAudio(context: Context, device: BluetoothDevice?) { fun disconnectAudio(context: Context, device: BluetoothDevice?) {
val bluetoothAdapter = context.getSystemService(BluetoothManager::class.java).adapter val bluetoothAdapter = context.getSystemService(BluetoothManager::class.java).adapter
@@ -590,4 +662,29 @@ class AirPodsService: Service() {
socket.outputStream?.write(bytes) socket.outputStream?.write(bytes)
socket.outputStream?.flush() socket.outputStream?.flush()
} }
override fun onDestroy() {
Log.d("AirPodsService", "Service stopped is being destroyed for some reason!")
try {
unregisterReceiver(bluetoothReceiver)
} catch (e: Exception) {
e.printStackTrace()
}
try {
unregisterReceiver(connectionReceiver)
} catch (e: Exception) {
e.printStackTrace()
}
try {
unregisterReceiver(disconnectionReceiver)
} catch (e: Exception) {
e.printStackTrace()
}
try {
unregisterReceiver(earReceiver)
} catch (e: Exception) {
e.printStackTrace()
}
super.onDestroy()
}
} }

View File

@@ -39,7 +39,6 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.KeyboardArrowRight import androidx.compose.material.icons.filled.KeyboardArrowRight
import androidx.compose.material3.CenterAlignedTopAppBar import androidx.compose.material3.CenterAlignedTopAppBar
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.IconButtonDefaults import androidx.compose.material3.IconButtonDefaults
@@ -61,9 +60,11 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.draw.scale import androidx.compose.ui.draw.scale
import androidx.compose.ui.draw.shadow import androidx.compose.ui.draw.shadow
import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.SolidColor
@@ -83,11 +84,17 @@ import androidx.compose.ui.unit.sp
import androidx.navigation.NavController import androidx.navigation.NavController
import com.primex.core.ExperimentalToolkitApi import com.primex.core.ExperimentalToolkitApi
import com.primex.core.blur.newBackgroundBlur import com.primex.core.blur.newBackgroundBlur
import me.kavishdevar.aln.AirPodsService
import kotlin.math.roundToInt import kotlin.math.roundToInt
@Preview
@Composable @Composable
fun BatteryView() { fun BatteryViewPreview() {
BatteryView(AirPodsService(), true)
}
@Composable
fun BatteryView(service: AirPodsService, preview: Boolean = false) {
val batteryStatus = remember { mutableStateOf<List<Battery>>(listOf()) } val batteryStatus = remember { mutableStateOf<List<Battery>>(listOf()) }
@Suppress("DEPRECATION") val batteryReceiver = remember { @Suppress("DEPRECATION") val batteryReceiver = remember {
object : BroadcastReceiver() { object : BroadcastReceiver() {
@@ -114,6 +121,16 @@ fun BatteryView() {
} }
} }
batteryStatus.value = service.getBattery()
if (preview) {
batteryStatus.value = listOf<Battery>(
Battery(BatteryComponent.LEFT, 100, BatteryStatus.CHARGING),
Battery(BatteryComponent.RIGHT, 50, BatteryStatus.NOT_CHARGING),
Battery(BatteryComponent.CASE, 5, BatteryStatus.CHARGING)
)
}
Row { Row {
Column ( Column (
modifier = Modifier modifier = Modifier
@@ -125,7 +142,7 @@ fun BatteryView() {
contentDescription = "Buds", contentDescription = "Buds",
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.scale(0.50f) .scale(0.80f)
) )
val left = batteryStatus.value.find { it.component == BatteryComponent.LEFT } val left = batteryStatus.value.find { it.component == BatteryComponent.LEFT }
val right = batteryStatus.value.find { it.component == BatteryComponent.RIGHT } val right = batteryStatus.value.find { it.component == BatteryComponent.RIGHT }
@@ -134,15 +151,44 @@ fun BatteryView() {
BatteryIndicator(right.level.let { left.level.coerceAtMost(it) }, left.status == BatteryStatus.CHARGING) BatteryIndicator(right.level.let { left.level.coerceAtMost(it) }, left.status == BatteryStatus.CHARGING)
} }
else { else {
Row { Row (
modifier = Modifier
.fillMaxWidth(),
horizontalArrangement = Arrangement.Center
) {
if (left?.status != BatteryStatus.DISCONNECTED) { if (left?.status != BatteryStatus.DISCONNECTED) {
Text(text = "\uDBC6\uDCE5", fontFamily = FontFamily(Font(R.font.sf_pro))) Row (
BatteryIndicator(left?.level ?: 0, left?.status == BatteryStatus.CHARGING) horizontalArrangement = Arrangement.SpaceEvenly,
Spacer(modifier = Modifier.width(16.dp)) modifier = Modifier
.weight(1f)
) {
Text(
text = "\uDBC6\uDCE5",
fontFamily = FontFamily(Font(R.font.sf_pro)),
)
BatteryIndicator(
left?.level ?: 0,
left?.status == BatteryStatus.CHARGING
)
}
} }
if (right?.status != BatteryStatus.DISCONNECTED) { if (right?.status != BatteryStatus.DISCONNECTED) {
Text(text = "\uDBC6\uDCE8", fontFamily = FontFamily(Font(R.font.sf_pro))) Row (
BatteryIndicator(right?.level ?: 0, right?.status == BatteryStatus.CHARGING) horizontalArrangement = Arrangement.Center,
modifier = Modifier
.weight(1f)
) {
Text(
text = "\uDBC6\uDCE8",
fontFamily = FontFamily(Font(R.font.sf_pro)),
modifier = Modifier
.fillMaxWidth(0.5f)
)
BatteryIndicator(
right?.level ?: 0,
right?.status == BatteryStatus.CHARGING
)
}
} }
} }
} }
@@ -160,6 +206,7 @@ fun BatteryView() {
contentDescription = "Case", contentDescription = "Case",
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.scale(1.25f)
) )
BatteryIndicator(case?.level ?: 0) BatteryIndicator(case?.level ?: 0)
} }
@@ -210,12 +257,11 @@ fun ToneVolumeSlider(service: AirPodsService, sharedPreferences: SharedPreferenc
}, },
valueRange = 0f..100f, valueRange = 0f..100f,
onValueChangeFinished = { onValueChangeFinished = {
// Round the value when the user stops sliding
sliderValue.floatValue = sliderValue.floatValue.roundToInt().toFloat() sliderValue.floatValue = sliderValue.floatValue.roundToInt().toFloat()
}, },
modifier = Modifier modifier = Modifier
.weight(1f) .weight(1f)
.height(36.dp), // Adjust height to ensure thumb fits well .height(36.dp),
colors = SliderDefaults.colors( colors = SliderDefaults.colors(
thumbColor = thumbColor, thumbColor = thumbColor,
activeTrackColor = activeTrackColor, activeTrackColor = activeTrackColor,
@@ -224,9 +270,9 @@ fun ToneVolumeSlider(service: AirPodsService, sharedPreferences: SharedPreferenc
thumb = { thumb = {
Box( Box(
modifier = Modifier modifier = Modifier
.size(24.dp) // Circular thumb size .size(24.dp)
.shadow(4.dp, CircleShape) // Apply shadow to the thumb .shadow(4.dp, CircleShape)
.background(thumbColor, CircleShape) // Circular thumb .background(thumbColor, CircleShape)
) )
}, },
track = { track = {
@@ -245,7 +291,7 @@ fun ToneVolumeSlider(service: AirPodsService, sharedPreferences: SharedPreferenc
) )
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxWidth(sliderValue.value / 100) .fillMaxWidth(sliderValue.floatValue / 100)
.height(4.dp) .height(4.dp)
.background(activeTrackColor, RoundedCornerShape(4.dp)) .background(activeTrackColor, RoundedCornerShape(4.dp))
) )
@@ -273,7 +319,6 @@ fun SinglePodANCSwitch(service: AirPodsService, sharedPreferences: SharedPrefere
) )
} }
// Update the service when the toggle is changed
fun updateSingleEnabled(enabled: Boolean) { fun updateSingleEnabled(enabled: Boolean) {
singleANCEnabled = enabled singleANCEnabled = enabled
sharedPreferences.edit().putBoolean("single_anc", enabled).apply() sharedPreferences.edit().putBoolean("single_anc", enabled).apply()
@@ -293,20 +338,19 @@ fun SinglePodANCSwitch(service: AirPodsService, sharedPreferences: SharedPrefere
color = if (isPressed.value) Color(0xFFE0E0E0) else Color.Transparent color = if (isPressed.value) Color(0xFFE0E0E0) else Color.Transparent
) )
.padding(horizontal = 12.dp, vertical = 12.dp) .padding(horizontal = 12.dp, vertical = 12.dp)
.pointerInput(Unit) { // Detect press state for iOS-like effect .pointerInput(Unit) {
detectTapGestures( detectTapGestures(
onPress = { onPress = {
isPressed.value = true isPressed.value = true
tryAwaitRelease() // Wait until release tryAwaitRelease()
isPressed.value = false isPressed.value = false
} }
) )
} }
.clickable( .clickable(
indication = null, // Disable ripple effect indication = null,
interactionSource = remember { MutableInteractionSource() } // Required for clickable interactionSource = remember { MutableInteractionSource() }
) { ) {
// Toggle the conversational awareness value
updateSingleEnabled(!singleANCEnabled) updateSingleEnabled(!singleANCEnabled)
}, },
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
@@ -321,7 +365,7 @@ fun SinglePodANCSwitch(service: AirPodsService, sharedPreferences: SharedPrefere
fontSize = 16.sp, fontSize = 16.sp,
color = textColor color = textColor
) )
Spacer(modifier = Modifier.height(4.dp)) // Small space between main text and description Spacer(modifier = Modifier.height(4.dp))
Text( Text(
text = "Allow AirPods to be put in noise cancellation mode when only one AirPods is in your ear.", text = "Allow AirPods to be put in noise cancellation mode when only one AirPods is in your ear.",
fontSize = 12.sp, fontSize = 12.sp,
@@ -346,7 +390,6 @@ fun VolumeControlSwitch(service: AirPodsService, sharedPreferences: SharedPrefer
) )
} }
// Update the service when the toggle is changed
fun updateVolumeControlEnabled(enabled: Boolean) { fun updateVolumeControlEnabled(enabled: Boolean) {
volumeControlEnabled = enabled volumeControlEnabled = enabled
sharedPreferences.edit().putBoolean("volume_control", enabled).apply() sharedPreferences.edit().putBoolean("volume_control", enabled).apply()
@@ -366,20 +409,19 @@ fun VolumeControlSwitch(service: AirPodsService, sharedPreferences: SharedPrefer
color = if (isPressed.value) Color(0xFFE0E0E0) else Color.Transparent color = if (isPressed.value) Color(0xFFE0E0E0) else Color.Transparent
) )
.padding(horizontal = 12.dp, vertical = 12.dp) .padding(horizontal = 12.dp, vertical = 12.dp)
.pointerInput(Unit) { // Detect press state for iOS-like effect .pointerInput(Unit) {
detectTapGestures( detectTapGestures(
onPress = { onPress = {
isPressed.value = true isPressed.value = true
tryAwaitRelease() // Wait until release tryAwaitRelease()
isPressed.value = false isPressed.value = false
} }
) )
} }
.clickable( .clickable(
indication = null, // Disable ripple effect indication = null,
interactionSource = remember { MutableInteractionSource() } // Required for clickable interactionSource = remember { MutableInteractionSource() }
) { ) {
// Toggle the conversational awareness value
updateVolumeControlEnabled(!volumeControlEnabled) updateVolumeControlEnabled(!volumeControlEnabled)
}, },
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
@@ -394,7 +436,7 @@ fun VolumeControlSwitch(service: AirPodsService, sharedPreferences: SharedPrefer
fontSize = 16.sp, fontSize = 16.sp,
color = textColor color = textColor
) )
Spacer(modifier = Modifier.height(4.dp)) // Small space between main text and description Spacer(modifier = Modifier.height(4.dp))
Text( Text(
text = "Adjust the volume by swiping up or down on the sensor located on the AirPods Pro stem.", text = "Adjust the volume by swiping up or down on the sensor located on the AirPods Pro stem.",
fontSize = 12.sp, fontSize = 12.sp,
@@ -435,8 +477,6 @@ fun AccessibilitySettings(service: AirPodsService, sharedPreferences: SharedPref
.padding(top = 2.dp) .padding(top = 2.dp)
) { ) {
// Tone Volume Slider
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
@@ -476,11 +516,10 @@ fun AccessibilitySettings(service: AirPodsService, sharedPreferences: SharedPref
@OptIn(ExperimentalMaterial3Api::class, ExperimentalToolkitApi::class) @OptIn(ExperimentalMaterial3Api::class, ExperimentalToolkitApi::class)
@SuppressLint("MissingPermission", "NewApi") @SuppressLint("MissingPermission", "NewApi")
@Composable @Composable
fun AirPodsSettingsScreen(device: BluetoothDevice?, service: AirPodsService?, fun AirPodsSettingsScreen(device: BluetoothDevice?, service: AirPodsService,
navController: NavController) { navController: NavController, isConnected: Boolean) {
val sharedPreferences = LocalContext.current.getSharedPreferences("settings", Context.MODE_PRIVATE) val sharedPreferences = LocalContext.current.getSharedPreferences("settings", MODE_PRIVATE)
var deviceName by remember { mutableStateOf(TextFieldValue(sharedPreferences.getString("name", device?.name ?: "") ?: "")) } var deviceName by remember { mutableStateOf(TextFieldValue(sharedPreferences.getString("name", device?.name ?: "") ?: "")) }
// 4B 61 76 69 73 68 E2 80 99 73 20 41 69 72 50 6F 64 73 20 50 72 6F
val verticalScrollState = rememberScrollState() val verticalScrollState = rememberScrollState()
@SuppressLint("UnusedMaterial3ScaffoldPaddingParameter") @SuppressLint("UnusedMaterial3ScaffoldPaddingParameter")
Scaffold( Scaffold(
@@ -490,6 +529,7 @@ fun AirPodsSettingsScreen(device: BluetoothDevice?, service: AirPodsService?,
0xFFF2F2F7 0xFFF2F2F7
), ),
topBar = { topBar = {
val darkMode = MaterialTheme.colorScheme.surface.luminance() < 0.5
CenterAlignedTopAppBar( CenterAlignedTopAppBar(
title = { title = {
Text( Text(
@@ -499,43 +539,56 @@ fun AirPodsSettingsScreen(device: BluetoothDevice?, service: AirPodsService?,
}, },
modifier = Modifier modifier = Modifier
.newBackgroundBlur( .newBackgroundBlur(
radius = 24.dp, // the radius of the blur effect, in pixels) radius = 24.dp,
), downsample = 0.5f,
)
.drawBehind {
val strokeWidth = 0.7.dp.value * density
val y = size.height - strokeWidth / 2
if (verticalScrollState.value > 55.dp.value * density) {
drawLine(
if (darkMode) Color.DarkGray else Color.LightGray,
Offset(0f, y),
Offset(size.width, y),
strokeWidth
)
}
},
colors = TopAppBarDefaults.centerAlignedTopAppBarColors( colors = TopAppBarDefaults.centerAlignedTopAppBarColors(
containerColor = if (MaterialTheme.colorScheme.surface.luminance() < 0.5) Color.Black.copy(0.2f) else Color(0xFFF2F2F7).copy(0.2f), containerColor = if (MaterialTheme.colorScheme.surface.luminance() < 0.5) Color.Black.copy(0.3f) else Color(0xFFF2F2F7).copy(0.2f),
) )
) )
HorizontalDivider(thickness = 3.dp, color = Color.DarkGray)
} }
) { paddingValues -> ) { paddingValues ->
Column( if (isConnected == true) {
modifier = Modifier Column(
.fillMaxSize() modifier = Modifier
.padding(horizontal = 8.dp) .fillMaxSize()
// .padding(top = 55.dp, bottom = 32.dp) .padding(horizontal = 16.dp)
.verticalScroll( .verticalScroll(
state = verticalScrollState, state = verticalScrollState,
enabled = true, enabled = true,
) )
) { ) {
Spacer(Modifier.height(75.dp)) Spacer(Modifier.height(75.dp))
LaunchedEffect(service) { LaunchedEffect(service) {
service?.let { service.let {
it.sendBroadcast(Intent(AirPodsNotifications.BATTERY_DATA).apply { it.sendBroadcast(Intent(AirPodsNotifications.BATTERY_DATA).apply {
putParcelableArrayListExtra("data", ArrayList(it.getBattery())) putParcelableArrayListExtra("data", ArrayList(it.getBattery()))
}) })
it.sendBroadcast(Intent(AirPodsNotifications.ANC_DATA).apply { it.sendBroadcast(Intent(AirPodsNotifications.ANC_DATA).apply {
putExtra("data", it.getANC()) putExtra("data", it.getANC())
}) })
}
} }
} val sharedPreferences = LocalContext.current.getSharedPreferences("settings", MODE_PRIVATE)
val sharedPreferences =
LocalContext.current.getSharedPreferences("settings", Context.MODE_PRIVATE)
if (service != null) { Spacer(modifier = Modifier.height(64.dp))
BatteryView()
BatteryView(service = service)
Spacer(modifier = Modifier.height(32.dp)) Spacer(modifier = Modifier.height(32.dp))
StyledTextField( StyledTextField(
name = "Name", name = "Name",
value = deviceName.text, value = deviceName.text,
@@ -627,8 +680,43 @@ fun AirPodsSettingsScreen(device: BluetoothDevice?, service: AirPodsService?,
) )
} }
} }
Spacer(Modifier.height(24.dp))
}
}
else {
Column(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 8.dp)
.verticalScroll(
state = verticalScrollState,
enabled = true,
),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Text(
text = "AirPods not connected",
style = TextStyle(
fontSize = 24.sp,
fontWeight = FontWeight.Medium,
color = if (MaterialTheme.colorScheme.surface.luminance() < 0.5) Color.White else Color.Black
),
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth()
)
Spacer(Modifier.height(24.dp))
Text(
text = "Please connect your AirPods to access settings. If you're stuck here, then try reopening the app again after closing it from the recents.\n(DO NOT KILL THE APP!)",
style = TextStyle(
fontSize = 16.sp,
fontWeight = FontWeight.Light,
color = if (MaterialTheme.colorScheme.surface.luminance() < 0.5) Color.White else Color.Black
),
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth()
)
} }
Spacer(Modifier.height(24.dp))
} }
} }
} }
@@ -658,7 +746,6 @@ fun NoiseControlSlider(service: AirPodsService, sharedPreferences: SharedPrefere
.padding(horizontal = 8.dp), .padding(horizontal = 8.dp),
horizontalAlignment = Alignment.CenterHorizontally horizontalAlignment = Alignment.CenterHorizontally
) { ) {
// Slider
Slider( Slider(
value = sliderValue.floatValue, value = sliderValue.floatValue,
onValueChange = { onValueChange = {
@@ -667,12 +754,11 @@ fun NoiseControlSlider(service: AirPodsService, sharedPreferences: SharedPrefere
}, },
valueRange = 0f..100f, valueRange = 0f..100f,
onValueChangeFinished = { onValueChangeFinished = {
// Round the value when the user stops sliding
sliderValue.floatValue = sliderValue.floatValue.roundToInt().toFloat() sliderValue.floatValue = sliderValue.floatValue.roundToInt().toFloat()
}, },
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.height(36.dp), // Adjust height to ensure thumb fits well .height(36.dp),
colors = SliderDefaults.colors( colors = SliderDefaults.colors(
thumbColor = thumbColor, thumbColor = thumbColor,
inactiveTrackColor = trackColor inactiveTrackColor = trackColor
@@ -680,9 +766,9 @@ fun NoiseControlSlider(service: AirPodsService, sharedPreferences: SharedPrefere
thumb = { thumb = {
Box( Box(
modifier = Modifier modifier = Modifier
.size(24.dp) // Circular thumb size .size(24.dp)
.shadow(4.dp, CircleShape) // Apply shadow to the thumb .shadow(4.dp, CircleShape)
.background(thumbColor, CircleShape) // Circular thumb .background(thumbColor, CircleShape)
) )
}, },
track = { track = {
@@ -704,7 +790,6 @@ fun NoiseControlSlider(service: AirPodsService, sharedPreferences: SharedPrefere
} }
) )
// Labels
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween horizontalArrangement = Arrangement.SpaceBetween
@@ -742,13 +827,9 @@ fun IndependentToggle(name: String, service: AirPodsService, functionName: Strin
val isDarkTheme = MaterialTheme.colorScheme.surface.luminance() < 0.5 val isDarkTheme = MaterialTheme.colorScheme.surface.luminance() < 0.5
val textColor = if (isDarkTheme) Color.White else Color.Black val textColor = if (isDarkTheme) Color.White else Color.Black
// Standardize the key
val snakeCasedName = name.replace(Regex("[\\W\\s]+"), "_").lowercase() val snakeCasedName = name.replace(Regex("[\\W\\s]+"), "_").lowercase()
// State for the toggle
var checked by remember { mutableStateOf(default) } var checked by remember { mutableStateOf(default) }
// Load initial state from SharedPreferences
LaunchedEffect(sharedPreferences) { LaunchedEffect(sharedPreferences) {
checked = sharedPreferences.getBoolean(snakeCasedName, true) checked = sharedPreferences.getBoolean(snakeCasedName, true)
} }
@@ -767,14 +848,12 @@ fun IndependentToggle(name: String, service: AirPodsService, functionName: Strin
.height(55.dp) .height(55.dp)
.padding(horizontal = 12.dp) .padding(horizontal = 12.dp)
.clickable { .clickable {
// Toggle checked state and save to SharedPreferences
checked = !checked checked = !checked
sharedPreferences sharedPreferences
.edit() .edit()
.putBoolean(snakeCasedName, checked) .putBoolean(snakeCasedName, checked)
.apply() .apply()
// Call the corresponding method in the service
val method = service::class.java.getMethod(functionName, Boolean::class.java) val method = service::class.java.getMethod(functionName, Boolean::class.java)
method.invoke(service, checked) method.invoke(service, checked)
}, },
@@ -786,8 +865,6 @@ fun IndependentToggle(name: String, service: AirPodsService, functionName: Strin
onCheckedChange = { onCheckedChange = {
checked = it checked = it
sharedPreferences.edit().putBoolean(snakeCasedName, it).apply() sharedPreferences.edit().putBoolean(snakeCasedName, it).apply()
// Call the corresponding method in the service
val method = service::class.java.getMethod(functionName, Boolean::class.java) val method = service::class.java.getMethod(functionName, Boolean::class.java)
method.invoke(service, it) method.invoke(service, it)
}, },
@@ -804,7 +881,6 @@ fun ConversationalAwarenessSwitch(service: AirPodsService, sharedPreferences: Sh
) )
} }
// Update the service when the toggle is changed
fun updateConversationalAwareness(enabled: Boolean) { fun updateConversationalAwareness(enabled: Boolean) {
conversationalAwarenessEnabled = enabled conversationalAwarenessEnabled = enabled
sharedPreferences.edit().putBoolean("conversational_awareness", enabled).apply() sharedPreferences.edit().putBoolean("conversational_awareness", enabled).apply()
@@ -824,20 +900,19 @@ fun ConversationalAwarenessSwitch(service: AirPodsService, sharedPreferences: Sh
color = if (isPressed.value) Color(0xFFE0E0E0) else Color.Transparent color = if (isPressed.value) Color(0xFFE0E0E0) else Color.Transparent
) )
.padding(horizontal = 12.dp, vertical = 12.dp) .padding(horizontal = 12.dp, vertical = 12.dp)
.pointerInput(Unit) { // Detect press state for iOS-like effect .pointerInput(Unit) {
detectTapGestures( detectTapGestures(
onPress = { onPress = {
isPressed.value = true isPressed.value = true
tryAwaitRelease() // Wait until release tryAwaitRelease()
isPressed.value = false isPressed.value = false
} }
) )
} }
.clickable( .clickable(
indication = null, // Disable ripple effect indication = null,
interactionSource = remember { MutableInteractionSource() } // Required for clickable interactionSource = remember { MutableInteractionSource() }
) { ) {
// Toggle the conversational awareness value
updateConversationalAwareness(!conversationalAwarenessEnabled) updateConversationalAwareness(!conversationalAwarenessEnabled)
}, },
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
@@ -852,7 +927,7 @@ fun ConversationalAwarenessSwitch(service: AirPodsService, sharedPreferences: Sh
fontSize = 16.sp, fontSize = 16.sp,
color = textColor color = textColor
) )
Spacer(modifier = Modifier.height(4.dp)) // Small space between main text and description Spacer(modifier = Modifier.height(4.dp))
Text( Text(
text = "Lowers media volume and reduces background noise when you start speaking to other people.", text = "Lowers media volume and reduces background noise when you start speaking to other people.",
fontSize = 12.sp, fontSize = 12.sp,
@@ -877,7 +952,6 @@ fun PersonalizedVolumeSwitch(service: AirPodsService, sharedPreferences: SharedP
) )
} }
// Update the service when the toggle is changed
fun updatePersonalizedVolume(enabled: Boolean) { fun updatePersonalizedVolume(enabled: Boolean) {
personalizedVolumeEnabled = enabled personalizedVolumeEnabled = enabled
sharedPreferences.edit().putBoolean("personalized_volume", enabled).apply() sharedPreferences.edit().putBoolean("personalized_volume", enabled).apply()
@@ -897,20 +971,19 @@ fun PersonalizedVolumeSwitch(service: AirPodsService, sharedPreferences: SharedP
color = if (isPressed.value) Color(0xFFE0E0E0) else Color.Transparent color = if (isPressed.value) Color(0xFFE0E0E0) else Color.Transparent
) )
.padding(horizontal = 12.dp, vertical = 12.dp) .padding(horizontal = 12.dp, vertical = 12.dp)
.pointerInput(Unit) { // Detect press state for iOS-like effect .pointerInput(Unit) {
detectTapGestures( detectTapGestures(
onPress = { onPress = {
isPressed.value = true isPressed.value = true
tryAwaitRelease() // Wait until release tryAwaitRelease()
isPressed.value = false isPressed.value = false
} }
) )
} }
.clickable( .clickable(
indication = null, // Disable ripple effect indication = null,
interactionSource = remember { MutableInteractionSource() } // Required for clickable interactionSource = remember { MutableInteractionSource() }
) { ) {
// Toggle the conversational awareness value
updatePersonalizedVolume(!personalizedVolumeEnabled) updatePersonalizedVolume(!personalizedVolumeEnabled)
}, },
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
@@ -925,7 +998,7 @@ fun PersonalizedVolumeSwitch(service: AirPodsService, sharedPreferences: SharedP
fontSize = 16.sp, fontSize = 16.sp,
color = textColor color = textColor
) )
Spacer(modifier = Modifier.height(4.dp)) // Small space between main text and description Spacer(modifier = Modifier.height(4.dp))
Text( Text(
text = "Adjusts the volume of media in response to your environment.", text = "Adjusts the volume of media in response to your environment.",
fontSize = 12.sp, fontSize = 12.sp,
@@ -951,7 +1024,6 @@ fun LoudSoundReductionSwitch(service: AirPodsService, sharedPreferences: SharedP
) )
} }
// Update the service when the toggle is changed
fun updateLoudSoundReduction(enabled: Boolean) { fun updateLoudSoundReduction(enabled: Boolean) {
loudSoundReductionEnabled = enabled loudSoundReductionEnabled = enabled
sharedPreferences.edit().putBoolean("loud_sound_reduction", enabled).apply() sharedPreferences.edit().putBoolean("loud_sound_reduction", enabled).apply()
@@ -971,20 +1043,19 @@ fun LoudSoundReductionSwitch(service: AirPodsService, sharedPreferences: SharedP
color = if (isPressed.value) Color(0xFFE0E0E0) else Color.Transparent color = if (isPressed.value) Color(0xFFE0E0E0) else Color.Transparent
) )
.padding(horizontal = 12.dp, vertical = 12.dp) .padding(horizontal = 12.dp, vertical = 12.dp)
.pointerInput(Unit) { // Detect press state for iOS-like effect .pointerInput(Unit) {
detectTapGestures( detectTapGestures(
onPress = { onPress = {
isPressed.value = true isPressed.value = true
tryAwaitRelease() // Wait until release tryAwaitRelease()
isPressed.value = false isPressed.value = false
} }
) )
} }
.clickable( .clickable(
indication = null, // Disable ripple effect indication = null,
interactionSource = remember { MutableInteractionSource() } // Required for clickable interactionSource = remember { MutableInteractionSource() }
) { ) {
// Toggle the conversational awareness value
updateLoudSoundReduction(!loudSoundReductionEnabled) updateLoudSoundReduction(!loudSoundReductionEnabled)
}, },
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
@@ -999,7 +1070,7 @@ fun LoudSoundReductionSwitch(service: AirPodsService, sharedPreferences: SharedP
fontSize = 16.sp, fontSize = 16.sp,
color = textColor color = textColor
) )
Spacer(modifier = Modifier.height(4.dp)) // Small space between main text and description Spacer(modifier = Modifier.height(4.dp))
Text( Text(
text = "Reduces loud sounds you are exposed to.", text = "Reduces loud sounds you are exposed to.",
fontSize = 12.sp, fontSize = 12.sp,
@@ -1393,14 +1464,15 @@ fun BatteryIndicator(batteryPercentage: Int, charging: Boolean = false) {
val batteryTextColor = MaterialTheme.colorScheme.onSurface val batteryTextColor = MaterialTheme.colorScheme.onSurface
// Battery indicator dimensions // Battery indicator dimensions
val batteryWidth = 30.dp val batteryWidth = 40.dp
val batteryHeight = 15.dp val batteryHeight = 15.dp
val batteryCornerRadius = 4.dp val batteryCornerRadius = 4.dp
val tipWidth = 5.dp val tipWidth = 4.dp
val tipHeight = batteryHeight * 0.3f val tipHeight = batteryHeight * 0.3f
Column(horizontalAlignment = Alignment.CenterHorizontally) { Column(
// Row for battery icon and tip horizontalAlignment = Alignment.CenterHorizontally
) {
Row( Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(0.dp), horizontalArrangement = Arrangement.spacedBy(0.dp),
@@ -1423,21 +1495,23 @@ fun BatteryIndicator(batteryPercentage: Int, charging: Boolean = false) {
if (charging) { if (charging) {
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxSize(), // Take up the entire size of the outer Box .padding(0.dp)
contentAlignment = Alignment.Center // Center the charging bolt within the Box .fillMaxSize(),
contentAlignment = Alignment.Center
) { ) {
Text( Text(
text = "\uDBC0\uDEE6", text = "\uDBC0\uDEE6",
fontSize = 12.sp, fontSize = 15.sp,
fontFamily = FontFamily(Font(R.font.sf_pro)), fontFamily = FontFamily(Font(R.font.sf_pro)),
color = Color.White, color = Color.White,
modifier = Modifier.align(Alignment.Center) modifier = Modifier
.align(Alignment.Center)
.padding(0.dp)
) )
} }
} }
} }
// Battery Tip (Protrusion)
Box( Box(
modifier = Modifier modifier = Modifier
.width(tipWidth) .width(tipWidth)
@@ -1455,7 +1529,6 @@ fun BatteryIndicator(batteryPercentage: Int, charging: Boolean = false) {
) )
} }
// Battery Percentage Text
Text( Text(
text = "$batteryPercentage%", text = "$batteryPercentage%",
color = batteryTextColor, color = batteryTextColor,

View File

@@ -20,23 +20,25 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.navigation.compose.NavHost import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController import androidx.navigation.compose.rememberNavController
import com.google.accompanist.permissions.ExperimentalPermissionsApi import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.isGranted import com.google.accompanist.permissions.rememberMultiplePermissionsState
import com.google.accompanist.permissions.rememberPermissionState
import com.google.accompanist.permissions.shouldShowRationale
import com.primex.core.ExperimentalToolkitApi import com.primex.core.ExperimentalToolkitApi
import me.kavishdevar.aln.ui.theme.ALNTheme import me.kavishdevar.aln.ui.theme.ALNTheme
lateinit var serviceConnection: ServiceConnection
lateinit var connectionStatusReceiver: BroadcastReceiver
@ExperimentalMaterial3Api @ExperimentalMaterial3Api
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
@@ -46,72 +48,94 @@ class MainActivity : ComponentActivity() {
enableEdgeToEdge() enableEdgeToEdge()
setContent { setContent {
ALNTheme { ALNTheme {
getSharedPreferences("settings", MODE_PRIVATE).edit().putLong("textColor",
MaterialTheme.colorScheme.onSurface.toArgb().toLong()).apply()
Main() Main()
startService(Intent(this, AirPodsService::class.java)) startService(Intent(this, AirPodsService::class.java))
} }
} }
} }
override fun onDestroy() {
try {
unbindService(serviceConnection)
Log.d("MainActivity", "Unbound service")
} catch (e: Exception) {
Log.e("MainActivity", "Error while unbinding service: $e")
}
try {
unregisterReceiver(connectionStatusReceiver)
Log.d("MainActivity", "Unregistered receiver")
} catch (e: Exception) {
Log.e("MainActivity", "Error while unregistering receiver: $e")
}
super.onDestroy()
}
} }
@SuppressLint("MissingPermission", "InlinedApi") @SuppressLint("MissingPermission", "InlinedApi")
@OptIn(ExperimentalPermissionsApi::class) @OptIn(ExperimentalPermissionsApi::class)
@Composable @Composable
fun Main() { fun Main() {
val bluetoothConnectPermissionState = rememberPermissionState( val isConnected = remember { mutableStateOf(false) }
permission = "android.permission.BLUETOOTH_CONNECT" val permissionState = rememberMultiplePermissionsState(
permissions = listOf(
"android.permission.BLUETOOTH_CONNECT",
"android.permission.POST_NOTIFICATIONS"
)
) )
if (bluetoothConnectPermissionState.status.isGranted) { val airPodsService = remember { mutableStateOf<AirPodsService?>(null) }
if (permissionState.allPermissionsGranted) {
Log.d("MainActivity", "HIIIIIIIIIIIIIII")
val context = LocalContext.current val context = LocalContext.current
val airPodsService = remember { mutableStateOf<AirPodsService?>(null) }
val navController = rememberNavController() val navController = rememberNavController()
connectionStatusReceiver = object : BroadcastReceiver() {
val disconnectReceiver = object : BroadcastReceiver() { override fun onReceive(context: Context, intent: Intent) {
override fun onReceive(context: Context?, intent: Intent?) { if (intent.action == AirPodsNotifications.AIRPODS_CONNECTED) {
Log.d("MainActivity", "Received DISCONNECTED broadcast") Log.d("MainActivity", "AirPods Connected intent received")
navController.navigate("notConnected") isConnected.value = true
}
else if (intent.action == AirPodsNotifications.AIRPODS_DISCONNECTED) {
Log.d("MainActivity", "AirPods Disconnected intent received")
isConnected.value = false
}
} }
} }
context.registerReceiver(disconnectReceiver, IntentFilter(AirPodsNotifications.AIRPODS_DISCONNECTED), val filter = IntentFilter().apply {
Context.RECEIVER_NOT_EXPORTED) addAction(AirPodsNotifications.AIRPODS_CONNECTED)
addAction(AirPodsNotifications.AIRPODS_DISCONNECTED)
addAction(AirPodsNotifications.AIRPODS_CONNECTION_DETECTED)
}
Log.d("MainActivity", "Registering Receiver")
context.registerReceiver(connectionStatusReceiver, filter, Context.RECEIVER_EXPORTED)
Log.d("MainActivity", "Registered Receiver")
// UI logic
NavHost( NavHost(
navController = navController, navController = navController,
startDestination = "notConnected", startDestination = "settings",
enterTransition = { slideInHorizontally(initialOffsetX = { it }, animationSpec = tween(300)) }, enterTransition = { slideInHorizontally(initialOffsetX = { it }, animationSpec = tween(300)) },
exitTransition = { slideOutHorizontally(targetOffsetX = { -it }, animationSpec = tween(300)) }, exitTransition = { slideOutHorizontally(targetOffsetX = { -it }, animationSpec = tween(300)) },
popEnterTransition = { slideInHorizontally(initialOffsetX = { -it }, animationSpec = tween(300)) }, popEnterTransition = { slideInHorizontally(initialOffsetX = { -it }, animationSpec = tween(300)) },
popExitTransition = { slideOutHorizontally(targetOffsetX = { it }, animationSpec = tween(300)) } popExitTransition = { slideOutHorizontally(targetOffsetX = { it }, animationSpec = tween(300)) }
) { ) {
composable("notConnected") {
Text("Not Connected...")
}
composable("settings") { composable("settings") {
AirPodsSettingsScreen( if (airPodsService.value != null) {
device = airPodsService.value?.device, AirPodsSettingsScreen(
service = airPodsService.value, device = airPodsService.value?.device,
navController = navController service = airPodsService.value!!,
) navController = navController,
isConnected = isConnected.value
)
}
} }
composable("debug") { composable("debug") {
DebugScreen(navController = navController) DebugScreen(navController = navController)
} }
} }
val receiver = object: BroadcastReceiver() { serviceConnection = remember {
override fun onReceive(p0: Context?, p1: Intent?) {
navController.navigate("settings")
navController.popBackStack("notConnected", inclusive = true)
}
}
context.registerReceiver(receiver, IntentFilter(AirPodsNotifications.AIRPODS_CONNECTED),
Context.RECEIVER_EXPORTED)
val serviceConnection = remember {
object : ServiceConnection { object : ServiceConnection {
override fun onServiceConnected(name: ComponentName?, service: IBinder?) { override fun onServiceConnected(name: ComponentName?, service: IBinder?) {
val binder = service as AirPodsService.LocalBinder val binder = service as AirPodsService.LocalBinder
@@ -126,28 +150,23 @@ fun Main() {
context.bindService(Intent(context, AirPodsService::class.java), serviceConnection, Context.BIND_AUTO_CREATE) context.bindService(Intent(context, AirPodsService::class.java), serviceConnection, Context.BIND_AUTO_CREATE)
val alreadyConnected = remember { mutableStateOf(false) } if (airPodsService.value?.isConnected == true) {
if (airPodsService.value?.isConnected == true && !alreadyConnected.value) { isConnected.value = true
Log.d("ALN", "Connected")
navController.navigate("settings")
} else {
Log.d("ALN", "Not connected")
navController.navigate("notConnected")
} }
} else { } else {
// Permission is not granted, request it // Permission is not granted, request it
Column ( Column (
modifier = Modifier.padding(24.dp), modifier = Modifier.padding(24.dp),
){ ){
val textToShow = if (bluetoothConnectPermissionState.status.shouldShowRationale) { val textToShow = if (permissionState.shouldShowRationale) {
// If the user has denied the permission but not permanently, explain why it's needed. // If the user has denied the permission but not permanently, explain why it's needed.
"The BLUETOOTH_CONNECT permission is important for this app. Please grant it to proceed." "Please enable Bluetooth and Notification permissions to use the app. The Nearby Devices is required to connect to your AirPods, and the notification is required to show the AirPods battery status."
} else { } else {
// If the user has permanently denied the permission, inform them to enable it in settings. // If the user has permanently denied the permission, inform them to enable it in settings.
"BLUETOOTH_CONNECT permission required for this feature. Please enable it in settings." "Please enable Bluetooth and Notification permissions in the app settings to use the app."
} }
Text(textToShow) Text(textToShow)
Button(onClick = { bluetoothConnectPermissionState.launchPermissionRequest() }) { Button(onClick = { permissionState.launchMultiplePermissionRequest() }) {
Text("Request permission") Text("Request permission")
} }
} }

View File

@@ -1,3 +1,5 @@
@file:Suppress("unused")
package me.kavishdevar.aln package me.kavishdevar.aln
import android.os.Parcelable import android.os.Parcelable
@@ -139,10 +141,18 @@ class AirPodsNotifications {
} }
fun setBattery(data: ByteArray) { fun setBattery(data: ByteArray) {
first = Battery(data[7].toInt(), data[9].toInt(), data[10].toInt()) first = if (data[10].toInt() == BatteryStatus.DISCONNECTED) {
second = Battery(data[12].toInt(), data[14].toInt(), data[15].toInt()) Battery(first.component, first.level, data[10].toInt())
} else {
Battery(data[7].toInt(), data[9].toInt(), data[10].toInt())
}
second = if (data[15].toInt() == BatteryStatus.DISCONNECTED) {
Battery(second.component, second.level, data[15].toInt())
} else {
Battery(data[12].toInt(), data[14].toInt(), data[15].toInt())
}
case = if (data[20].toInt() == BatteryStatus.DISCONNECTED && case.status != BatteryStatus.DISCONNECTED) { case = if (data[20].toInt() == BatteryStatus.DISCONNECTED && case.status != BatteryStatus.DISCONNECTED) {
Battery(data[17].toInt(), case.level, data[20].toInt()) Battery(case.component, case.level, data[20].toInt())
} else { } else {
Battery(data[17].toInt(), data[19].toInt(), data[20].toInt()) Battery(data[17].toInt(), data[19].toInt(), data[20].toInt())
} }

View File

@@ -91,16 +91,6 @@ class Window (context: Context) {
batteryCaseText.text = batteryStatus.find { it.component == BatteryComponent.CASE }?.let { batteryCaseText.text = batteryStatus.find { it.component == BatteryComponent.CASE }?.let {
"\uDBC3\uDE6C ${it.level}%" "\uDBC3\uDE6C ${it.level}%"
} ?: "" } ?: ""
// bText.text =
// batteryStatus.joinToString(separator = "") {
// when (it.component) {
// BatteryComponent.LEFT -> "\uDBC6\uDCE5 ${it.level}%"
// BatteryComponent.RIGHT -> "\uDBC6\uDCE8 ${it.level}%"
// BatteryComponent.CASE -> "\uDBC6\uDCE6 ${it.level}%"
// else -> ""
// }
// }
// composeView.setContent { // composeView.setContent {
// Row ( // Row (
// modifier = Modifier // modifier = Modifier

View File

@@ -0,0 +1,15 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="141.5dp"
android:height="109.06dp"
android:viewportWidth="141.5"
android:viewportHeight="109.06">
<path
android:fillColor="#FF000000"
android:pathData="M0,0h141.5v109.06h-141.5z"
android:strokeAlpha="0"
android:fillAlpha="0"/>
<path
android:pathData="M37.88,109L103.63,109C129.19,109 141.5,96.75 141.5,71.31L141.5,41.63L102.81,41.63C101.75,44.44 99.13,46.25 95.81,46.25L45.75,46.25C42.5,46.25 39.75,44.44 38.75,41.63L0,41.63L0,71.31C0,96.75 12.38,109 37.88,109ZM70.75,70.06C67.63,70.13 65.06,67.5 65.06,64.44C65.06,61.31 67.63,58.69 70.75,58.69C73.88,58.69 76.5,61.31 76.5,64.44C76.5,67.38 73.88,70 70.75,70.06ZM0,35.94L38.63,35.94C39.63,33.06 42.38,31.31 45.63,31.31L95.75,31.31C99,31.31 101.69,33.06 102.69,35.94L141.31,35.94L141.31,33.94C141.31,11.13 127.81,0 103.44,0L37.88,0C13.56,0 0,11.13 0,33.94Z"
android:fillColor="#ffffff"
android:fillAlpha="0.85"/>
</vector>

View File

@@ -0,0 +1,23 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="94.19dp"
android:height="123.31dp"
android:viewportWidth="94.19"
android:viewportHeight="123.31">
<path
android:fillColor="#FF000000"
android:pathData="M0,0h94.19v123.31h-94.19z"
android:strokeAlpha="0"
android:fillAlpha="0"/>
<path
android:pathData="M46.81,73.29L46.81,111.06C46.81,114.94 44.25,116.94 40.38,116.94L35.5,116.94C31.63,116.94 29.06,114.94 29.06,111.06L29.06,69.82C33.68,71.55 39.25,72.79 46.81,73.29Z"
android:fillColor="#ffffff"
android:fillAlpha="0.85"/>
<path
android:pathData="M62,22.63C62.94,23.8 64.28,25.84 65.55,28.29C51.19,30.59 40.75,43.46 40.75,56.13C40.75,60.05 41.47,63.51 42.75,66.49C34.56,65.53 29.44,63.52 24.75,60.88C14.44,54.94 6.31,45.31 6.31,31.5C6.31,15.56 17.56,6.5 31.38,6.38C42.06,6.25 54,11.5 62,22.63ZM19.44,21.25C18.31,22.5 18.38,24.38 19.69,25.44L29,33.19C30.19,34.25 32.13,34.13 33.19,32.81C34.25,31.5 34.13,29.63 32.75,28.56L23.63,20.81C22.31,19.75 20.44,19.94 19.44,21.25Z"
android:fillColor="#ffffff"
android:fillAlpha="0.85"/>
<path
android:pathData="M65.13,74.25C76.31,74.25 87.81,65.38 87.81,51.81C87.81,42.75 81.44,33.88 70.19,33.88C57,33.88 46.81,45.31 46.81,56.13C46.81,68.25 55.38,74.25 65.13,74.25ZM68.19,64.75C66.44,63.25 67.44,60.25 71,55.69C74.69,51.19 77.63,49.69 79.5,51.31C81.19,52.81 80.25,55.88 76.69,60.31C72.94,64.75 70,66.31 68.19,64.75Z"
android:fillColor="#ffffff"
android:fillAlpha="0.85"/>
</vector>

View File

@@ -0,0 +1,23 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="94.19dp"
android:height="123.31dp"
android:viewportWidth="94.19"
android:viewportHeight="123.31">
<path
android:fillColor="#FF000000"
android:pathData="M0,0h94.19v123.31h-94.19z"
android:strokeAlpha="0"
android:fillAlpha="0"/>
<path
android:pathData="M65.06,111.06C65.06,114.94 62.56,116.94 58.69,116.94L53.81,116.94C49.88,116.94 47.31,114.94 47.31,111.06L47.31,73.29C54.87,72.79 60.44,71.56 65.06,69.84Z"
android:fillColor="#ffffff"
android:fillAlpha="0.85"/>
<path
android:pathData="M87.81,31.5C87.81,45.31 79.69,54.94 69.44,60.88C64.74,63.52 59.6,65.54 51.37,66.5C52.65,63.51 53.38,60.05 53.38,56.13C53.38,43.47 43,30.6 28.61,28.29C29.89,25.84 31.25,23.8 32.19,22.63C40.19,11.5 52.06,6.25 62.81,6.38C76.56,6.5 87.81,15.56 87.81,31.5ZM70.56,20.81L61.38,28.56C60.06,29.63 59.94,31.5 61,32.81C62.06,34.13 63.94,34.25 65.19,33.19L74.44,25.44C75.75,24.38 75.81,22.5 74.75,21.25C73.75,19.94 71.81,19.75 70.56,20.81Z"
android:fillColor="#ffffff"
android:fillAlpha="0.85"/>
<path
android:pathData="M29.06,74.25C38.81,74.25 47.38,68.25 47.38,56.13C47.38,45.31 37.19,33.88 23.94,33.88C12.75,33.88 6.31,42.75 6.31,51.81C6.31,65.38 17.88,74.25 29.06,74.25ZM26,64.75C24.19,66.31 21.25,64.75 17.5,60.31C13.88,55.88 12.94,52.81 14.69,51.31C16.56,49.69 19.44,51.19 23.19,55.69C26.75,60.25 27.75,63.25 26,64.75Z"
android:fillColor="#ffffff"
android:fillAlpha="0.85"/>
</vector>

View File

@@ -0,0 +1,13 @@
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<TextView
android:id="@+id/notification_title"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textSize="16sp"
/>
</LinearLayout>

View File

@@ -0,0 +1,49 @@
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<TextView
android:id="@+id/notification_title"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textSize="16sp"
android:textColor="?android:attr/textColorPrimary"
android:fontFamily="@font/sf_pro" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:paddingTop="8dp"
android:gravity="center">
<TextView
android:id="@+id/left_battery_notification"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:fontFamily="@font/sf_pro"
android:textSize="15sp"
android:paddingEnd="16dp"
android:layout_weight="1"
tools:ignore="RtlSymmetry" />
<TextView
android:id="@+id/right_battery_notification"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:fontFamily="@font/sf_pro"
android:textSize="15sp"
android:layout_weight="1"
android:paddingEnd="16dp"
tools:ignore="RtlSymmetry" />
<TextView
android:id="@+id/case_battery_notification"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:fontFamily="@font/sf_pro"
android:layout_weight="1"
android:textSize="15sp" />
</LinearLayout>
</LinearLayout>

View File

@@ -1,10 +1,5 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources> <resources>
<color name="purple_200">#FFBB86FC</color>
<color name="purple_500">#FF6200EE</color>
<color name="purple_700">#FF3700B3</color>
<color name="teal_200">#FF03DAC5</color>
<color name="teal_700">#FF018786</color>
<color name="black">#FF000000</color> <color name="black">#FF000000</color>
<color name="white">#FFFFFFFF</color> <color name="white">#FFFFFFFF</color>
</resources> </resources>

View File

@@ -1,5 +1,4 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources> <resources>
<style name="Theme.ALN" parent="android:Theme.Material.Light.NoActionBar" /> <style name="Theme.ALN" parent="android:Theme.Material.Light.NoActionBar" />
</resources> </resources>