some progress on cross-device, and new dynamic island thingy!

This commit is contained in:
Kavish Devar
2025-01-30 03:49:44 +05:30
parent 8b57a97a54
commit b6966f8c39
19 changed files with 730 additions and 472 deletions

View File

@@ -23,6 +23,7 @@
android:usesPermissionFlags="neverForLocation"
tools:ignore="UnusedAttribute" />
<uses-permission android:name="android.permission.BLUETOOTH_ADVERTISE" />
<uses-permission android:name="android.permission.READ_PHONE_STATE" />
<application
android:allowBackup="true"

View File

@@ -1,17 +1,17 @@
/*
* AirPods like Normal (ALN) - Bringing Apple-only features to Linux and Android for seamless AirPods functionality!
*
*
* Copyright (C) 2024 Kavish Devar
*
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License.
*
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
@@ -135,7 +135,8 @@ fun Main() {
permissions = listOf(
"android.permission.BLUETOOTH_CONNECT",
"android.permission.BLUETOOTH_SCAN",
"android.permission.POST_NOTIFICATIONS"
"android.permission.POST_NOTIFICATIONS",
"android.permission.READ_PHONE_STATE"
)
)
val airPodsService = remember { mutableStateOf<AirPodsService?>(null) }
@@ -308,7 +309,6 @@ fun Main() {
isConnected.value = true
}
} else {
// Permission is not granted, request it
Column (
modifier = Modifier.padding(24.dp),
){
@@ -325,4 +325,4 @@ fun Main() {
}
}
}
}
}

View File

@@ -50,6 +50,8 @@ import android.os.Handler
import android.os.IBinder
import android.os.Looper
import android.os.ParcelUuid
import android.telephony.PhoneStateListener
import android.telephony.TelephonyManager
import android.util.Log
import android.util.TypedValue
import android.view.View
@@ -76,9 +78,10 @@ import me.kavishdevar.aln.utils.BatteryStatus
import me.kavishdevar.aln.utils.CrossDevice
import me.kavishdevar.aln.utils.CrossDevicePackets
import me.kavishdevar.aln.utils.Enums
import me.kavishdevar.aln.utils.IslandWindow
import me.kavishdevar.aln.utils.LongPressPackets
import me.kavishdevar.aln.utils.MediaController
import me.kavishdevar.aln.utils.Window
import me.kavishdevar.aln.utils.PopupWindow
import me.kavishdevar.aln.widgets.BatteryWidget
import me.kavishdevar.aln.widgets.NoiseControlWidget
import org.lsposed.hiddenapibypass.HiddenApiBypass
@@ -114,7 +117,7 @@ object ServiceManager {
// @Suppress("unused")
class AirPodsService : Service() {
private var macAddress = ""
var macAddress = ""
inner class LocalBinder : Binder() {
fun getService(): AirPodsService = this@AirPodsService
@@ -126,6 +129,9 @@ class AirPodsService : Service() {
private val _packetLogsFlow = MutableStateFlow<Set<String>>(emptySet())
val packetLogsFlow: StateFlow<Set<String>> get() = _packetLogsFlow
private lateinit var telephonyManager: TelephonyManager
private lateinit var phoneStateListener: PhoneStateListener
override fun onCreate() {
super.onCreate()
sharedPreferencesLogs = getSharedPreferences("packet_logs", MODE_PRIVATE)
@@ -166,10 +172,25 @@ class AirPodsService : Service() {
if (popupShown) {
return
}
val window = Window(service.applicationContext)
window.open(name, batteryNotification)
val popupWindow = PopupWindow(service.applicationContext)
popupWindow.open(name, batteryNotification)
popupShown = true
}
var islandOpen = false
var islandWindow: IslandWindow? = null
@SuppressLint("MissingPermission")
fun showIsland(service: Service, batteryPercentage: Int, takingOver: Boolean = false) {
Log.d("AirPodsService", "Showing island window")
islandWindow = IslandWindow(service.applicationContext)
islandWindow!!.show(sharedPreferences.getString("name", "AirPods Pro").toString(), batteryPercentage, this, takingOver)
}
@OptIn(ExperimentalMaterial3Api::class)
fun startMainActivity() {
val intent = Intent(this, MainActivity::class.java)
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
startActivity(intent)
}
@Suppress("ClassName")
private object bluetoothReceiver : BroadcastReceiver() {
@@ -220,23 +241,7 @@ class AirPodsService : Service() {
object BatteryChangedIntentReceiver : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent) {
if (intent.action == Intent.ACTION_BATTERY_CHANGED) {
val level = intent.getIntExtra("level", 0)
val scale = intent.getIntExtra("scale", 100)
val batteryPct = level * 100 / scale
val charging = intent.getIntExtra(
BatteryManager.EXTRA_STATUS,
-1
) == BatteryManager.BATTERY_STATUS_CHARGING
if (ServiceManager.getService()?.widgetMobileBatteryEnabled == true) {
val appWidgetManager = AppWidgetManager.getInstance(context)
val componentName = ComponentName(context!!, BatteryWidget::class.java)
val widgetIds = appWidgetManager.getAppWidgetIds(componentName)
val remoteViews = RemoteViews(context.packageName, R.layout.battery_widget)
remoteViews.setTextViewText(R.id.phone_battery_widget, "$batteryPct%")
remoteViews.setProgressBar(R.id.phone_battery_progress, 100, batteryPct, false)
appWidgetManager.updateAppWidget(widgetIds, remoteViews)
}
ServiceManager.getService()?.updateBatteryWidget()
} else if (intent.action == AirPodsNotifications.DISCONNECT_RECEIVERS) {
try {
context?.unregisterReceiver(this)
@@ -568,13 +573,55 @@ class AirPodsService : Service() {
Log.d("AirPodsService", "Service started")
ServiceManager.setService(this)
startForegroundNotification()
val audioManager =
this@AirPodsService.getSystemService(AUDIO_SERVICE) as AudioManager
MediaController.initialize(
audioManager,
this@AirPodsService.getSharedPreferences(
"settings",
MODE_PRIVATE
)
)
Log.d("AirPodsService", "Initializing CrossDevice")
CrossDevice.init(this)
Log.d("AirPodsService", "CrossDevice initialized")
CoroutineScope(Dispatchers.IO).launch {
CrossDevice.init(this@AirPodsService)
Log.d("AirPodsService", "CrossDevice initialized")
}
sharedPreferences = getSharedPreferences("settings", MODE_PRIVATE)
macAddress = sharedPreferences.getString("mac_address", "") ?: ""
telephonyManager = getSystemService(TELEPHONY_SERVICE) as TelephonyManager
phoneStateListener = object : PhoneStateListener() {
@SuppressLint("SwitchIntDef")
override fun onCallStateChanged(state: Int, phoneNumber: String?) {
super.onCallStateChanged(state, phoneNumber)
when (state) {
TelephonyManager.CALL_STATE_RINGING -> {
if (CrossDevice.isAvailable && !isConnectedLocally && earDetectionNotification.status.contains(0x00)) takeOver()
}
TelephonyManager.CALL_STATE_OFFHOOK -> {
if (CrossDevice.isAvailable && !isConnectedLocally && earDetectionNotification.status.contains(0x00)) takeOver()
}
}
}
}
telephonyManager.listen(phoneStateListener, PhoneStateListener.LISTEN_CALL_STATE)
if (sharedPreferences.getBoolean("show_phone_battery_in_widget", true)) {
widgetMobileBatteryEnabled = true
val batteryChangedIntentFilter = IntentFilter(Intent.ACTION_BATTERY_CHANGED)
batteryChangedIntentFilter.addAction(AirPodsNotifications.DISCONNECT_RECEIVERS)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
registerReceiver(
BatteryChangedIntentReceiver,
batteryChangedIntentFilter,
RECEIVER_EXPORTED
)
} else {
registerReceiver(BatteryChangedIntentReceiver, batteryChangedIntentFilter)
}
}
val serviceIntentFilter = IntentFilter().apply {
addAction("android.bluetooth.device.action.ACL_CONNECTED")
addAction("android.bluetooth.device.action.ACL_DISCONNECTED")
@@ -605,13 +652,16 @@ class AirPodsService : Service() {
putString("name", name)
}
}
Log.d("AirPodsQuickSwitchServices", CrossDevice.isAvailable.toString())
if (!CrossDevice.checkAirPodsConnectionStatus()) {
Log.d("AirPodsCrossDevice", CrossDevice.isAvailable.toString())
if (!CrossDevice.isAvailable) {
Log.d("AirPodsService", "$name connected")
showPopup(this@AirPodsService, name.toString())
connectToSocket(device!!)
isConnectedLocally = true
macAddress = device!!.address
sharedPreferences.edit {
putString("mac_address", macAddress)
}
updateNotificationContent(
true,
name.toString(),
@@ -626,7 +676,30 @@ class AirPodsService : Service() {
}
}
}
val showIslandReceiver = object: BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
if (intent?.action == "me.kavishdevar.aln.cross_device_island") {
showIsland(this@AirPodsService, batteryNotification.getBattery().find { it.component == BatteryComponent.LEFT}?.level!!.coerceAtMost(batteryNotification.getBattery().find { it.component == BatteryComponent.RIGHT}?.level!!))
} else if (intent?.action == AirPodsNotifications.Companion.DISCONNECT_RECEIVERS) {
try {
context?.unregisterReceiver(this)
} catch (e: Exception) {
e.printStackTrace()
}
}
}
}
val showIslandIntentFilter = IntentFilter().apply {
addAction("me.kavishdevar.aln.cross_device_island")
addAction(AirPodsNotifications.DISCONNECT_RECEIVERS)
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
registerReceiver(showIslandReceiver, showIslandIntentFilter, RECEIVER_EXPORTED)
} else {
registerReceiver(showIslandReceiver, showIslandIntentFilter)
}
val deviceIntentFilter = IntentFilter().apply {
addAction(AirPodsNotifications.Companion.AIRPODS_CONNECTION_DETECTED)
@@ -641,11 +714,6 @@ class AirPodsService : Service() {
registerReceiver(bluetoothReceiver, serviceIntentFilter)
}
widgetMobileBatteryEnabled = getSharedPreferences("settings", MODE_PRIVATE).getBoolean(
"show_phone_battery_in_widget",
true
)
val bluetoothAdapter = getSystemService(BluetoothManager::class.java).adapter
if (bluetoothAdapter.isEnabled) {
CoroutineScope(Dispatchers.IO).launch {
@@ -682,8 +750,12 @@ class AirPodsService : Service() {
if (profile == BluetoothProfile.A2DP) {
val connectedDevices = proxy.connectedDevices
if (connectedDevices.isNotEmpty()) {
if (!CrossDevice.checkAirPodsConnectionStatus()) {
if (!CrossDevice.isAvailable) {
connectToSocket(device)
macAddress = device.address
sharedPreferences.edit {
putString("mac_address", macAddress)
}
}
this@AirPodsService.sendBroadcast(
Intent(AirPodsNotifications.Companion.AIRPODS_CONNECTED)
@@ -720,12 +792,27 @@ class AirPodsService : Service() {
}
}
@SuppressLint("MissingPermission")
fun takeOver() {
Log.d("AirPodsService", "Taking over audio")
CrossDevice.sendRemotePacket(CrossDevicePackets.REQUEST_DISCONNECT.packet)
Log.d("AirPodsService", macAddress)
device = getSystemService<BluetoothManager>(BluetoothManager::class.java).adapter.bondedDevices.find {
it.address == macAddress
}
if (device != null) {
connectToSocket(device!!)
connectAudio(this, device)
}
showIsland(this, batteryNotification.getBattery().find { it.component == BatteryComponent.LEFT}?.level!!.coerceAtMost(batteryNotification.getBattery().find { it.component == BatteryComponent.RIGHT}?.level!!), true)
}
@SuppressLint("MissingPermission", "UnspecifiedRegisterReceiverFlag")
fun connectToSocket(device: BluetoothDevice) {
HiddenApiBypass.addHiddenApiExemptions("Landroid/bluetooth/BluetoothSocket;")
val uuid: ParcelUuid = ParcelUuid.fromString("74ec2172-0bad-4d01-8f77-997b2be0722a")
if (isConnectedLocally != true) {
if (isConnectedLocally != true && !CrossDevice.isAvailable) {
try {
socket = HiddenApiBypass.newInstance(
BluetoothSocket::class.java,
@@ -799,15 +886,6 @@ class AirPodsService : Service() {
)
while (socket.isConnected == true) {
socket.let {
val audioManager =
this@AirPodsService.getSystemService(AUDIO_SERVICE) as AudioManager
MediaController.initialize(
audioManager,
this@AirPodsService.getSharedPreferences(
"settings",
MODE_PRIVATE
)
)
val buffer = ByteArray(1024)
val bytesRead = it.inputStream.read(buffer)
var data: ByteArray = byteArrayOf()
@@ -852,11 +930,16 @@ class AirPodsService : Service() {
} else {
data[0] == 0x00.toByte() && data[1] == 0x00.toByte()
}
val newInEarData = listOf(
data[0] == 0x00.toByte(),
data[1] == 0x00.toByte()
)
if (inEarData.sorted() == listOf(false, false) && newInEarData.sorted() != listOf(false, false) && islandWindow?.isVisible != true) {
showIsland(this@AirPodsService, batteryNotification.getBattery().find { it.component == BatteryComponent.LEFT}?.level!!.coerceAtMost(batteryNotification.getBattery().find { it.component == BatteryComponent.RIGHT}?.level!!))
}
if (newInEarData == listOf(false, false) && islandWindow?.isVisible == true) {
islandWindow?.close()
}
if (newInEarData.contains(true) && inEarData == listOf(
false,
false
@@ -1296,6 +1379,9 @@ class AirPodsService : Service() {
e.printStackTrace()
} finally {
bluetoothAdapter.closeProfileProxy(BluetoothProfile.A2DP, proxy)
if (MediaController.pausedForCrossDevice) {
MediaController.sendPlay()
}
}
}
}
@@ -1521,6 +1607,7 @@ class AirPodsService : Service() {
} catch (e: Exception) {
e.printStackTrace()
}
telephonyManager.listen(phoneStateListener, PhoneStateListener.LISTEN_NONE)
super.onDestroy()
}
}

View File

@@ -1,3 +1,22 @@
/*
* AirPods like Normal (ALN) - Bringing Apple-only features to Linux and Android for seamless AirPods functionality!
*
* Copyright (C) 2024 Kavish Devar
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package me.kavishdevar.aln.utils
import android.annotation.SuppressLint
@@ -10,6 +29,7 @@ import android.bluetooth.le.AdvertiseData
import android.bluetooth.le.AdvertiseSettings
import android.bluetooth.le.BluetoothLeAdvertiser
import android.content.Context
import android.content.Intent
import android.content.SharedPreferences
import android.os.ParcelUuid
import android.util.Log
@@ -44,25 +64,28 @@ object CrossDevice {
var batteryBytes: ByteArray = byteArrayOf()
var ancBytes: ByteArray = byteArrayOf()
private lateinit var sharedPreferences: SharedPreferences
private const val packetLogKey = "packet_log"
private const val PACKET_LOG_KEY = "packet_log"
private var earDetectionStatus = listOf(false, false)
@SuppressLint("MissingPermission")
fun init(context: Context) {
Log.d("AirPodsQuickSwitchService", "Initializing CrossDevice")
sharedPreferences = context.getSharedPreferences("packet_logs", Context.MODE_PRIVATE)
sharedPreferences.edit().putBoolean("CrossDeviceIsAvailable", false).apply()
this.bluetoothAdapter = context.getSystemService(BluetoothManager::class.java).adapter
this.bluetoothLeAdvertiser = bluetoothAdapter.bluetoothLeAdvertiser
startAdvertising()
startServer()
initialized = true
CoroutineScope(Dispatchers.IO).launch {
Log.d("CrossDevice", "Initializing CrossDevice")
sharedPreferences = context.getSharedPreferences("packet_logs", Context.MODE_PRIVATE)
sharedPreferences.edit().putBoolean("CrossDeviceIsAvailable", false).apply()
this@CrossDevice.bluetoothAdapter = context.getSystemService(BluetoothManager::class.java).adapter
this@CrossDevice.bluetoothLeAdvertiser = bluetoothAdapter.bluetoothLeAdvertiser
startAdvertising()
startServer()
initialized = true
}
}
@SuppressLint("MissingPermission")
fun startServer() {
serverSocket = bluetoothAdapter.listenUsingRfcommWithServiceRecord("ALNCrossDevice", uuid)
Log.d("AirPodsQuickSwitchService", "Server started")
private fun startServer() {
CoroutineScope(Dispatchers.IO).launch {
serverSocket = bluetoothAdapter.listenUsingRfcommWithServiceRecord("ALNCrossDevice", uuid)
Log.d("CrossDevice", "Server started")
while (serverSocket != null) {
try {
val socket = serverSocket!!.accept()
@@ -76,29 +99,31 @@ object CrossDevice {
@SuppressLint("MissingPermission")
private fun startAdvertising() {
val settings = AdvertiseSettings.Builder()
.setAdvertiseMode(AdvertiseSettings.ADVERTISE_MODE_LOW_LATENCY)
.setTxPowerLevel(AdvertiseSettings.ADVERTISE_TX_POWER_HIGH)
.setConnectable(true)
.build()
CoroutineScope(Dispatchers.IO).launch {
val settings = AdvertiseSettings.Builder()
.setAdvertiseMode(AdvertiseSettings.ADVERTISE_MODE_LOW_LATENCY)
.setTxPowerLevel(AdvertiseSettings.ADVERTISE_TX_POWER_HIGH)
.setConnectable(true)
.build()
val data = AdvertiseData.Builder()
.setIncludeDeviceName(true)
.addManufacturerData(MANUFACTURER_ID, MANUFACTURER_DATA.toByteArray())
.addServiceUuid(ParcelUuid(uuid))
.build()
val data = AdvertiseData.Builder()
.setIncludeDeviceName(true)
.addManufacturerData(MANUFACTURER_ID, MANUFACTURER_DATA.toByteArray())
.addServiceUuid(ParcelUuid(uuid))
.build()
bluetoothLeAdvertiser.startAdvertising(settings, data, advertiseCallback)
Log.d("AirPodsQuickSwitchService", "BLE Advertising started")
bluetoothLeAdvertiser.startAdvertising(settings, data, advertiseCallback)
Log.d("CrossDevice", "BLE Advertising started")
}
}
private val advertiseCallback = object : AdvertiseCallback() {
override fun onStartSuccess(settingsInEffect: AdvertiseSettings) {
Log.d("AirPodsQuickSwitchService", "BLE Advertising started successfully")
Log.d("CrossDevice", "BLE Advertising started successfully")
}
override fun onStartFailure(errorCode: Int) {
Log.e("AirPodsQuickSwitchService", "BLE Advertising failed with error code: $errorCode")
Log.e("CrossDevice", "BLE Advertising failed with error code: $errorCode")
}
}
@@ -113,9 +138,9 @@ object CrossDevice {
}
fun sendReceivedPacket(packet: ByteArray) {
Log.d("AirPodsQuickSwitchService", "Sending packet to remote device")
if (clientSocket == null) {
Log.d("AirPodsQuickSwitchService", "Client socket is null")
Log.d("CrossDevice", "Sending packet to remote device")
if (clientSocket == null || clientSocket!!.outputStream != null) {
Log.d("CrossDevice", "Client socket is null")
return
}
clientSocket?.outputStream?.write(CrossDevicePackets.AIRPODS_DATA_HEADER.packet + packet)
@@ -124,14 +149,14 @@ object CrossDevice {
private fun logPacket(packet: ByteArray, source: String) {
val packetHex = packet.joinToString(" ") { "%02X".format(it) }
val logEntry = "$source: $packetHex"
val logs = sharedPreferences.getStringSet(packetLogKey, mutableSetOf())?.toMutableSet() ?: mutableSetOf()
val logs = sharedPreferences.getStringSet(PACKET_LOG_KEY, mutableSetOf())?.toMutableSet() ?: mutableSetOf()
logs.add(logEntry)
sharedPreferences.edit().putStringSet(packetLogKey, logs).apply()
sharedPreferences.edit().putStringSet(PACKET_LOG_KEY, logs).apply()
}
@SuppressLint("MissingPermission")
private fun handleClientConnection(socket: BluetoothSocket) {
Log.d("AirPodsQuickSwitchService", "Client connected")
Log.d("CrossDevice", "Client connected")
clientSocket = socket
val inputStream = socket.inputStream
val buffer = ByteArray(1024)
@@ -141,7 +166,7 @@ object CrossDevice {
bytes = inputStream.read(buffer)
val packet = buffer.copyOf(bytes)
logPacket(packet, "Relay")
Log.d("AirPodsQuickSwitchService", "Received packet: ${packet.joinToString("") { "%02x".format(it) }}")
Log.d("CrossDevice", "Received packet: ${packet.joinToString("") { "%02x".format(it) }}")
if (bytes == -1) {
break
} else if (packet.contentEquals(CrossDevicePackets.REQUEST_DISCONNECT.packet)) {
@@ -153,36 +178,49 @@ object CrossDevice {
isAvailable = false
sharedPreferences.edit().putBoolean("CrossDeviceIsAvailable", false).apply()
} else if (packet.contentEquals(CrossDevicePackets.REQUEST_BATTERY_BYTES.packet)) {
Log.d("AirPodsQuickSwitchService", "Received battery request, battery data: ${batteryBytes.joinToString("") { "%02x".format(it) }}")
Log.d("CrossDevice", "Received battery request, battery data: ${batteryBytes.joinToString("") { "%02x".format(it) }}")
sendRemotePacket(batteryBytes)
} else if (packet.contentEquals(CrossDevicePackets.REQUEST_ANC_BYTES.packet)) {
Log.d("AirPodsQuickSwitchService", "Received ANC request")
Log.d("CrossDevice", "Received ANC request")
sendRemotePacket(ancBytes)
} else if (packet.contentEquals(CrossDevicePackets.REQUEST_CONNECTION_STATUS.packet)) {
Log.d("AirPodsQuickSwitchService", "Received connection status request")
Log.d("CrossDevice", "Received connection status request")
sendRemotePacket(if (ServiceManager.getService()?.isConnectedLocally == true) CrossDevicePackets.AIRPODS_CONNECTED.packet else CrossDevicePackets.AIRPODS_DISCONNECTED.packet)
}
else {
} else {
if (packet.sliceArray(0..3).contentEquals(CrossDevicePackets.AIRPODS_DATA_HEADER.packet)) {
isAvailable = true
sharedPreferences.edit().putBoolean("CrossDeviceIsAvailable", true).apply()
val trimmedPacket = packet.drop(CrossDevicePackets.AIRPODS_DATA_HEADER.packet.size).toByteArray()
Log.d("AirPodsQuickSwitchService", "Received relayed packet, with ${sharedPreferences.getBoolean("CrossDeviceIsAvailable", false)} | ${ServiceManager.getService()?.batteryNotification?.isBatteryData(trimmedPacket)}")
Log.d("AirPodsQuickSwitchService", "Received relayed packet: ${trimmedPacket.joinToString("") { "%02x".format(it) }}")
var trimmedPacket = packet.drop(CrossDevicePackets.AIRPODS_DATA_HEADER.packet.size).toByteArray()
Log.d("CrossDevice", "Received relayed packet, with ${sharedPreferences.getBoolean("CrossDeviceIsAvailable", false)} | ${ServiceManager.getService()?.earDetectionNotification?.isEarDetectionData(trimmedPacket)}")
Log.d("CrossDevice", "Received relayed packet: ${trimmedPacket.joinToString("") { "%02x".format(it) }}")
if (ServiceManager.getService()?.isConnectedLocally == true) {
val packetInHex = trimmedPacket.joinToString("") { "%02x".format(it) }
ServiceManager.getService()?.sendPacket(packetInHex)
} else if (ServiceManager.getService()?.batteryNotification?.isBatteryData(trimmedPacket) == true) {
batteryBytes = trimmedPacket
ServiceManager.getService()?.batteryNotification?.setBattery(trimmedPacket)
Log.d("AirPodsQuickSwitchService", "Battery data: ${ServiceManager.getService()?.batteryNotification?.getBattery()[0]?.level}")
Log.d("CrossDevice", "Battery data: ${ServiceManager.getService()?.batteryNotification?.getBattery()[0]?.level}")
ServiceManager.getService()?.updateBatteryWidget()
ServiceManager.getService()?.sendBatteryBroadcast()
ServiceManager.getService()?.sendBatteryNotification()
} else if (ServiceManager.getService()?.ancNotification?.isANCData(trimmedPacket) == true) {
ServiceManager.getService()?.ancNotification?.setStatus(trimmedPacket)
ServiceManager.getService()?.sendANCBroadcast()
ServiceManager.getService()?.updateNoiseControlWidget()
ancBytes = trimmedPacket
} else if (ServiceManager.getService()?.earDetectionNotification?.isEarDetectionData(trimmedPacket) == true) {
Log.d("CrossDevice", "Ear detection data: ${trimmedPacket.joinToString("") { "%02x".format(it) }}")
ServiceManager.getService()?.earDetectionNotification?.setStatus(trimmedPacket)
val newEarDetectionStatus = listOf(
ServiceManager.getService()?.earDetectionNotification?.status?.get(0) == 0x00.toByte(),
ServiceManager.getService()?.earDetectionNotification?.status?.get(1) == 0x00.toByte()
)
if (earDetectionStatus == listOf(false, false) && newEarDetectionStatus.contains(true)) {
ServiceManager.getService()?.applicationContext?.sendBroadcast(
Intent("me.kavishdevar.aln.cross_device_island")
)
}
earDetectionStatus = newEarDetectionStatus
}
}
}
@@ -190,31 +228,13 @@ object CrossDevice {
}
fun sendRemotePacket(byteArray: ByteArray) {
if (clientSocket == null) {
Log.d("AirPodsQuickSwitchService", "Client socket is null")
if (clientSocket == null || clientSocket!!.outputStream == null) {
Log.d("CrossDevice", "Client socket is null")
return
}
clientSocket?.outputStream?.write(byteArray)
clientSocket?.outputStream?.flush()
logPacket(byteArray, "Sent")
Log.d("AirPodsQuickSwitchService", "Sent packet to remote device")
Log.d("CrossDevice", "Sent packet to remote device")
}
fun checkAirPodsConnectionStatus(): Boolean {
Log.d("AirPodsQuickSwitchService", "Checking AirPods connection status")
if (clientSocket == null) {
Log.d("AirPodsQuickSwitchService", "Client socket is null - linux probably not connected.")
return false
}
return try {
clientSocket?.outputStream?.write(CrossDevicePackets.REQUEST_CONNECTION_STATUS.packet)
val buffer = ByteArray(1024)
val bytes = clientSocket?.inputStream?.read(buffer) ?: -1
val packet = buffer.copyOf(bytes)
packet.contentEquals(CrossDevicePackets.AIRPODS_CONNECTED.packet)
} catch (e: IOException) {
Log.e("AirPodsQuickSwitchService", "Error checking connection status", e)
false
}
}
}
}

View File

@@ -0,0 +1,148 @@
/*
* AirPods like Normal (ALN) - Bringing Apple-only features to Linux and Android for seamless AirPods functionality!
*
* Copyright (C) 2024 Kavish Devar
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package me.kavishdevar.aln.utils
import android.animation.Animator
import android.animation.AnimatorListenerAdapter
import android.animation.ObjectAnimator
import android.animation.PropertyValuesHolder
import android.annotation.SuppressLint
import android.content.Context
import android.content.res.Resources
import android.graphics.PixelFormat
import android.net.Uri
import android.os.Handler
import android.os.Looper
import android.util.Log.e
import android.view.Gravity
import android.view.LayoutInflater
import android.view.View
import android.view.WindowManager
import android.view.animation.AnticipateOvershootInterpolator
import android.widget.ProgressBar
import android.widget.TextView
import android.widget.VideoView
import androidx.core.content.ContextCompat.getString
import me.kavishdevar.aln.R
import me.kavishdevar.aln.services.ServiceManager
class IslandWindow(context: Context) {
private val windowManager: WindowManager = context.getSystemService(Context.WINDOW_SERVICE) as WindowManager
@SuppressLint("InflateParams")
private val islandView: View = LayoutInflater.from(context).inflate(R.layout.island_window, null)
private var isClosing = false
val isVisible: Boolean
get() = islandView.parent != null && islandView.visibility == View.VISIBLE
@SuppressLint("SetTextI18n")
fun show(name: String, batteryPercentage: Int, context: Context, takingOver: Boolean) {
if (ServiceManager.getService()?.islandOpen == true) return
else ServiceManager.getService()?.islandOpen = true
val displayMetrics = Resources.getSystem().displayMetrics
val width = (displayMetrics.widthPixels * 0.95).toInt()
val params = WindowManager.LayoutParams(
width,
WindowManager.LayoutParams.WRAP_CONTENT,
WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY,
WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL,
PixelFormat.TRANSLUCENT
).apply {
gravity = Gravity.TOP or Gravity.CENTER_HORIZONTAL
}
islandView.visibility = View.VISIBLE
islandView.findViewById<TextView>(R.id.island_battery_text).text = "$batteryPercentage%"
islandView.findViewById<TextView>(R.id.island_device_name).text = name
islandView.setOnClickListener {
ServiceManager.getService()?.startMainActivity()
close()
}
if (takingOver) {
islandView.findViewById<TextView>(R.id.island_connected_text).text = getString(context, R.string.island_taking_over_text)
} else if (CrossDevice.isAvailable) {
islandView.findViewById<TextView>(R.id.island_connected_text).text = getString(context, R.string.island_connected_remote_text)
} else {
islandView.findViewById<TextView>(R.id.island_connected_text).text = getString(context, R.string.island_connected_text)
}
val batteryProgressBar = islandView.findViewById<ProgressBar>(R.id.island_battery_progress)
batteryProgressBar.progress = batteryPercentage
batteryProgressBar.isIndeterminate = false
val videoView = islandView.findViewById<VideoView>(R.id.island_video_view)
val videoUri = Uri.parse("android.resource://me.kavishdevar.aln/${R.raw.island}")
videoView.setVideoURI(videoUri)
videoView.setOnPreparedListener { mediaPlayer ->
mediaPlayer.isLooping = true
videoView.start()
}
windowManager.addView(islandView, params)
val scaleX = PropertyValuesHolder.ofFloat(View.SCALE_X, 0.5f, 1f)
val scaleY = PropertyValuesHolder.ofFloat(View.SCALE_Y, 0.5f, 1f)
val translationY = PropertyValuesHolder.ofFloat(View.TRANSLATION_Y, -200f, 0f)
ObjectAnimator.ofPropertyValuesHolder(islandView, scaleX, scaleY, translationY).apply {
duration = 700
interpolator = AnticipateOvershootInterpolator()
start()
}
Handler(Looper.getMainLooper()).postDelayed({
close()
}, 4500)
}
fun close() {
try {
if (isClosing) return
isClosing = true
ServiceManager.getService()?.islandOpen = false
val videoView = islandView.findViewById<VideoView>(R.id.island_video_view)
videoView.stopPlayback()
val scaleX = PropertyValuesHolder.ofFloat(View.SCALE_X, 1f, 0.5f)
val scaleY = PropertyValuesHolder.ofFloat(View.SCALE_Y, 1f, 0.5f)
val translationY = PropertyValuesHolder.ofFloat(View.TRANSLATION_Y, 0f, -200f)
ObjectAnimator.ofPropertyValuesHolder(islandView, scaleX, scaleY, translationY).apply {
duration = 700
interpolator = AnticipateOvershootInterpolator()
addListener(object : AnimatorListenerAdapter() {
override fun onAnimationEnd(animation: Animator) {
islandView.visibility = View.GONE
try {
windowManager.removeView(islandView)
} catch (e: Exception) {
e("IslandWindow", "Error removing view: $e")
}
isClosing = false
}
})
start()
}
} catch (e: Exception) {
e.printStackTrace()
}
}
}

View File

@@ -1,17 +1,17 @@
/*
* AirPods like Normal (ALN) - Bringing Apple-only features to Linux and Android for seamless AirPods functionality!
*
*
* Copyright (C) 2024 Kavish Devar
*
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License.
*
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
@@ -25,6 +25,7 @@ import android.os.Handler
import android.os.Looper
import android.util.Log
import android.view.KeyEvent
import me.kavishdevar.aln.services.ServiceManager
object MediaController {
private var initialVolume: Int? = null
@@ -34,14 +35,19 @@ object MediaController {
private lateinit var sharedPreferences: SharedPreferences
private val handler = Handler(Looper.getMainLooper())
var pausedForCrossDevice = false
private var relativeVolume: Boolean = false
private var conversationalAwarenessVolume: Int = 1/12
private var conversationalAwarenessPauseMusic: Boolean = false
fun initialize(audioManager: AudioManager, sharedPreferences: SharedPreferences) {
if (this::audioManager.isInitialized) {
return
}
this.audioManager = audioManager
this.sharedPreferences = sharedPreferences
Log.d("MediaController", "Initializing MediaController")
relativeVolume = sharedPreferences.getBoolean("relative_conversational_awareness_volume", false)
conversationalAwarenessVolume = sharedPreferences.getInt("conversational_awareness_volume", 100/12)
conversationalAwarenessPauseMusic = sharedPreferences.getBoolean("conversational_awareness_pause_music", false)
@@ -74,6 +80,14 @@ object MediaController {
userPlayedTheMedia = audioManager.isMusicActive
}, 7) // i have no idea why android sends an event a hundred times after the user does something.
}
Log.d("MediaController", "Ear detection status: ${ServiceManager.getService()?.earDetectionNotification?.status}, music active: ${audioManager.isMusicActive} and cross device available: ${CrossDevice.isAvailable}")
if (!pausedForCrossDevice && CrossDevice.isAvailable && ServiceManager.getService()?.earDetectionNotification?.status?.contains(0x00) == true && audioManager.isMusicActive) {
if (ServiceManager.getService()?.isConnectedLocally == false) {
sendPause(true)
pausedForCrossDevice = true
}
ServiceManager.getService()?.takeOver()
}
}
}
@@ -161,4 +175,4 @@ object MediaController {
}
})
}
}
}

View File

@@ -1,17 +1,17 @@
/*
* AirPods like Normal (ALN) - Bringing Apple-only features to Linux and Android for seamless AirPods functionality!
*
*
* Copyright (C) 2024 Kavish Devar
*
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License.
*
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
@@ -44,7 +44,7 @@ import kotlinx.coroutines.launch
import me.kavishdevar.aln.R
@SuppressLint("InflateParams", "ClickableViewAccessibility")
class Window (context: Context) {
class PopupWindow(context: Context) {
private val mView: View
@Suppress("DEPRECATION")
@@ -56,13 +56,12 @@ class Window (context: Context) {
gravity = Gravity.BOTTOM
dimAmount = 0.3f
flags = WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS or
WindowManager.LayoutParams.FLAG_FULLSCREEN or
WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or
WindowManager.LayoutParams.FLAG_DIM_BEHIND or
WindowManager.LayoutParams.FLAG_WATCH_OUTSIDE_TOUCH
WindowManager.LayoutParams.FLAG_FULLSCREEN or
WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or
WindowManager.LayoutParams.FLAG_DIM_BEHIND or
WindowManager.LayoutParams.FLAG_WATCH_OUTSIDE_TOUCH
}
private val mWindowManager: WindowManager
init {
@@ -72,14 +71,13 @@ class Window (context: Context) {
mParams.y = 0
mParams.gravity = Gravity.BOTTOM
mView.setOnClickListener(View.OnClickListener {
mView.setOnClickListener {
close()
})
}
mView.findViewById<ImageButton>(R.id.close_button)
.setOnClickListener {
close()
}
mView.findViewById<ImageButton>(R.id.close_button).setOnClickListener {
close()
}
val ll = mView.findViewById<LinearLayout>(R.id.linear_layout)
ll.setOnClickListener {
@@ -88,11 +86,11 @@ class Window (context: Context) {
@Suppress("DEPRECATION")
mView.systemUiVisibility = View.SYSTEM_UI_FLAG_LAYOUT_STABLE or
View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION or
View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN or
View.SYSTEM_UI_FLAG_HIDE_NAVIGATION or
View.SYSTEM_UI_FLAG_FULLSCREEN or
View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY
View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION or
View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN or
View.SYSTEM_UI_FLAG_HIDE_NAVIGATION or
View.SYSTEM_UI_FLAG_FULLSCREEN or
View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY
mView.setOnTouchListener { _, event ->
if (event.action == MotionEvent.ACTION_DOWN) {
@@ -116,7 +114,6 @@ class Window (context: Context) {
try {
if (mView.windowToken == null) {
if (mView.parent == null) {
// Add the view initially off-screen
mWindowManager.addView(mView, mParams)
mView.findViewById<TextView>(R.id.name).text = name
val vid = mView.findViewById<VideoView>(R.id.video)
@@ -143,14 +140,13 @@ class Window (context: Context) {
"\uDBC3\uDE6C ${it.level}%"
} ?: ""
// Slide-up animation
val displayMetrics = mView.context.resources.displayMetrics
val screenHeight = displayMetrics.heightPixels
mView.translationY = screenHeight.toFloat() // Start below the screen
mView.translationY = screenHeight.toFloat()
ObjectAnimator.ofFloat(mView, "translationY", 0f).apply {
duration = 500 // Animation duration in milliseconds
interpolator = DecelerateInterpolator() // Smooth deceleration
duration = 500
interpolator = DecelerateInterpolator()
start()
}
@@ -168,8 +164,8 @@ class Window (context: Context) {
fun close() {
try {
ObjectAnimator.ofFloat(mView, "translationY", mView.height.toFloat()).apply {
duration = 500 // Animation duration in milliseconds
interpolator = AccelerateInterpolator() // Smooth acceleration
duration = 500
interpolator = AccelerateInterpolator()
addListener(object : AnimatorListenerAdapter() {
override fun onAnimationEnd(animation: Animator) {
try {
@@ -185,4 +181,4 @@ class Window (context: Context) {
Log.d("PopupService", e.toString())
}
}
}
}

View File

@@ -0,0 +1,9 @@
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item>
<shape android:shape="rectangle">
<solid android:color="#000000"/>
<corners android:radius="56dp"/>
<padding android:left="4dp" android:top="4dp" android:right="4dp" android:bottom="4dp"/>
</shape>
</item>
</layer-list>

View File

@@ -0,0 +1,12 @@
<rotate
xmlns:android="http://schemas.android.com/apk/res/android"
android:fromDegrees="270"
android:toDegrees="270">
<shape
android:shape="ring"
android:innerRadiusRatio="3.0"
android:thickness="4dp"
android:useLevel="true">
<solid android:color="#0f4524" />
</shape>
</rotate>

View File

@@ -0,0 +1,11 @@
<rotate
xmlns:android="http://schemas.android.com/apk/res/android"
android:fromDegrees="270"
android:toDegrees="270">
<shape
android:shape="ring"
android:innerRadiusRatio="3.0"
android:thickness="4dp" >
<solid android:color="#1ceb72" />
</shape>
</rotate>

View File

@@ -0,0 +1,105 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/island_window_layout"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_margin="16dp"
android:layout_weight="0.95"
android:background="@drawable/island_background"
android:elevation="4dp"
android:gravity="center"
android:minHeight="115dp"
android:orientation="horizontal"
android:outlineAmbientShadowColor="#4EFFFFFF"
android:outlineSpotShadowColor="#4EFFFFFF"
android:padding="8dp">
<VideoView
android:id="@+id/island_video_view"
android:layout_width="80dp"
android:layout_height="80dp"
android:layout_marginStart="8dp"
android:importantForAccessibility="no" />
<LinearLayout
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_margin="0dp"
android:layout_weight="1"
android:gravity="bottom"
android:orientation="vertical"
android:padding="12dp">
<TextView
android:id="@+id/island_connected_text"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_margin="0dp"
android:fontFamily="@font/sf_pro"
android:gravity="bottom"
android:padding="0dp"
android:text="@string/island_connected_text"
android:textColor="#707072"
android:includeFontPadding="false"
android:lineSpacingExtra="0dp"
android:lineSpacingMultiplier="1"
android:textSize="16sp" />
<TextView
android:id="@+id/island_device_name"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_margin="0dp"
android:fontFamily="@font/sf_pro"
android:gravity="bottom"
android:padding="0dp"
android:text="AirPods Pro"
android:textColor="@color/white"
android:textSize="24sp"
android:includeFontPadding="false"
android:lineSpacingExtra="0dp"
android:lineSpacingMultiplier="1"
tools:ignore="HardcodedText" />
</LinearLayout>
<FrameLayout
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:gravity="center">
<ProgressBar
style="@style/Widget.AppCompat.ProgressBar.Horizontal"
android:layout_width="84dp"
android:layout_height="84dp"
android:layout_gravity="center"
android:indeterminate="false"
android:max="100"
android:progress="100"
android:progressDrawable="@drawable/island_battery_background" />
<ProgressBar
android:id="@+id/island_battery_progress"
style="@style/Widget.AppCompat.ProgressBar.Horizontal"
android:layout_width="84dp"
android:layout_height="84dp"
android:layout_gravity="center"
android:indeterminate="false"
android:max="100"
android:progress="50"
android:progressDrawable="@drawable/island_battery_progress" />
<TextView
android:id="@+id/island_battery_text"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:fontFamily="@font/sf_pro"
android:gravity="center"
android:text="50%"
android:textColor="#1ceb72"
android:textSize="16sp"
android:textStyle="bold"
tools:ignore="HardcodedText" />
</FrameLayout>
</LinearLayout>

Binary file not shown.

View File

@@ -1,45 +1,48 @@
<resources>
<string name="app_name" translatable="false">ALN</string>
<string name="title_activity_custom_device" translatable="false">GATT Testing</string>
<string name="app_widget_description">See your AirPods battery status right from your home screen!</string>
<string name="accessibility">Accessibility</string>
<string name="tone_volume">Tone Volume</string>
<string name="audio">Audio</string>
<string name="adaptive_audio">Adaptive Audio</string>
<string name="adaptive_audio_description">Adaptive audio dynamically responds to your environment and cancels or allows external noise. You can customize Adaptive Audio to allow more or less noise.</string>
<string name="buds">Buds</string>
<string name="case_alt">Case</string>
<string name="test">Test</string>
<string name="name">Name</string>
<string name="noise_control">Noise Control</string>
<string name="off">Off</string>
<string name="transparency">Transparency</string>
<string name="adaptive">Adaptive</string>
<string name="noise_cancellation">Noise Cancellation</string>
<string name="press_and_hold_airpods">Press and Hold AirPods</string>
<string name="left">Left</string>
<string name="right">Right</string>
<string name="adjusts_volume">Adjusts the volume of media in response to your environment</string>
<string name="conversational_awareness">Conversational Awareness</string>
<string name="conversational_awareness_description">Lowers media volume and reduces background noise when you start speaking to other people.</string>
<string name="personalized_volume">Personalized Volume</string>
<string name="personalized_volume_description">Adjusts the volume of media in response to your environment.</string>
<string name="less_noise">Less Noise</string>
<string name="more_noise">More Noise</string>
<string name="noise_cancellation_single_airpod">Noise Cancellation with Single AirPod</string>
<string name="noise_cancellation_single_airpod_description">Allow AirPods to be put in noise cancellation mode when only one AirPods is in your ear.</string>
<string name="volume_control">Volume Control</string>
<string name="volume_control_description">Adjust the volume by swiping up or down on the sensor located on the AirPods Pro stem.</string>
<string name="airpods_not_connected">AirPods not connected</string>
<string name="airpods_not_connected_description">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!)</string>
<string name="back">Back</string>
<string name="app_settings">App Settings</string>
<string name="conversational_awareness_customization">Conversational Awareness</string>
<string name="relative_conversational_awareness_volume">Relative volume</string>
<string name="relative_conversational_awareness_volume_description">Reduces to a percentage of the current volume instead of the maximum volume.</string>
<string name="conversational_awareness_pause_music">Pause Music</string>
<string name="conversational_awareness_pause_music_description">When you start speaking, music will be paused.</string>
<string name="appwidget_text">EXAMPLE</string>
<string name="add_widget">Add widget</string>
<string name="noise_control_widget_description">Control Noise Control Mode directly from your Home Screen.</string>
<string name="app_name" translatable="false">ALN</string>
<string name="title_activity_custom_device" translatable="false">GATT Testing</string>
<string name="app_widget_description">See your AirPods battery status right from your home screen!</string>
<string name="accessibility">Accessibility</string>
<string name="tone_volume">Tone Volume</string>
<string name="audio">Audio</string>
<string name="adaptive_audio">Adaptive Audio</string>
<string name="adaptive_audio_description">Adaptive audio dynamically responds to your environment and cancels or allows external noise. You can customize Adaptive Audio to allow more or less noise.</string>
<string name="buds">Buds</string>
<string name="case_alt">Case</string>
<string name="test">Test</string>
<string name="name">Name</string>
<string name="noise_control">Noise Control</string>
<string name="off">Off</string>
<string name="transparency">Transparency</string>
<string name="adaptive">Adaptive</string>
<string name="noise_cancellation">Noise Cancellation</string>
<string name="press_and_hold_airpods">Press and Hold AirPods</string>
<string name="left">Left</string>
<string name="right">Right</string>
<string name="adjusts_volume">Adjusts the volume of media in response to your environment</string>
<string name="conversational_awareness">Conversational Awareness</string>
<string name="conversational_awareness_description">Lowers media volume and reduces background noise when you start speaking to other people.</string>
<string name="personalized_volume">Personalized Volume</string>
<string name="personalized_volume_description">Adjusts the volume of media in response to your environment.</string>
<string name="less_noise">Less Noise</string>
<string name="more_noise">More Noise</string>
<string name="noise_cancellation_single_airpod">Noise Cancellation with Single AirPod</string>
<string name="noise_cancellation_single_airpod_description">Allow AirPods to be put in noise cancellation mode when only one AirPods is in your ear.</string>
<string name="volume_control">Volume Control</string>
<string name="volume_control_description">Adjust the volume by swiping up or down on the sensor located on the AirPods Pro stem.</string>
<string name="airpods_not_connected">AirPods not connected</string>
<string name="airpods_not_connected_description">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!)</string>
<string name="back">Back</string>
<string name="app_settings">App Settings</string>
<string name="conversational_awareness_customization">Conversational Awareness</string>
<string name="relative_conversational_awareness_volume">Relative volume</string>
<string name="relative_conversational_awareness_volume_description">Reduces to a percentage of the current volume instead of the maximum volume.</string>
<string name="conversational_awareness_pause_music">Pause Music</string>
<string name="conversational_awareness_pause_music_description">When you start speaking, music will be paused.</string>
<string name="appwidget_text">EXAMPLE</string>
<string name="add_widget">Add widget</string>
<string name="noise_control_widget_description">Control Noise Control Mode directly from your Home Screen.</string>
<string name="island_connected_text">Connected</string>
<string name="island_connected_remote_text">Connected to Linux</string>
<string name="island_taking_over_text">Moved to phone</string>
</resources>