mirror of
https://github.com/kavishdevar/librepods.git
synced 2026-01-28 22:01:50 +00:00
some progress on cross-device, and new dynamic island thingy!
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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() {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
9
android/app/src/main/res/drawable/island_background.xml
Normal file
9
android/app/src/main/res/drawable/island_background.xml
Normal 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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
105
android/app/src/main/res/layout/island_window.xml
Normal file
105
android/app/src/main/res/layout/island_window.xml
Normal 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>
|
||||
BIN
android/app/src/main/res/raw/island.mp4
Normal file
BIN
android/app/src/main/res/raw/island.mp4
Normal file
Binary file not shown.
@@ -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>
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#include <QObject>
|
||||
#include <QSystemTrayIcon>
|
||||
#include <QMenu>
|
||||
#include <QBluetoothDeviceDiscoveryAgent>
|
||||
#include <QBluetoothSocket>
|
||||
#include <QDBusInterface>
|
||||
#include "BluetoothHandler.h"
|
||||
|
||||
class AirPodsTrayApp : public QObject {
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
AirPodsTrayApp();
|
||||
|
||||
public slots:
|
||||
void connectToDevice(const QString &address);
|
||||
void showAvailableDevices();
|
||||
void setNoiseControlMode(int mode);
|
||||
void setConversationalAwareness(bool enabled);
|
||||
void updateNoiseControlMenu(int mode);
|
||||
void updateBatteryTooltip(const QString &status);
|
||||
void updateTrayIcon(const QString &status);
|
||||
void handleEarDetection(const QString &status);
|
||||
|
||||
private slots:
|
||||
void onTrayIconActivated(QSystemTrayIcon::ActivationReason reason);
|
||||
void onDeviceDiscovered(const QBluetoothDeviceInfo &device);
|
||||
void onDiscoveryFinished();
|
||||
void onDeviceConnected(const QBluetoothAddress &address);
|
||||
void onDeviceDisconnected(const QBluetoothAddress &address);
|
||||
void onPhoneDataReceived();
|
||||
|
||||
signals:
|
||||
void noiseControlModeChanged(int mode);
|
||||
void earDetectionStatusChanged(const QString &status);
|
||||
void batteryStatusChanged(const QString &status);
|
||||
|
||||
private:
|
||||
void initializeMprisInterface();
|
||||
void connectToPhone();
|
||||
void relayPacketToPhone(const QByteArray &packet);
|
||||
void handlePhonePacket(const QByteArray &packet);
|
||||
|
||||
QSystemTrayIcon *trayIcon;
|
||||
QMenu *trayMenu;
|
||||
QBluetoothDeviceDiscoveryAgent *discoveryAgent;
|
||||
QBluetoothSocket *socket = nullptr;
|
||||
QBluetoothSocket *phoneSocket = nullptr;
|
||||
QDBusInterface *mprisInterface;
|
||||
QString connectedDeviceMacAddress;
|
||||
};
|
||||
@@ -1,109 +0,0 @@
|
||||
#include "BluetoothHandler.h"
|
||||
#include "PacketDefinitions.h"
|
||||
#include <QLoggingCategory>
|
||||
|
||||
Q_LOGGING_CATEGORY(bluetoothHandler, "bluetoothHandler")
|
||||
|
||||
#define LOG_INFO(msg) qCInfo(bluetoothHandler) << "\033[32m" << msg << "\033[0m"
|
||||
#define LOG_WARN(msg) qCWarning(bluetoothHandler) << "\033[33m" << msg << "\033[0m"
|
||||
#define LOG_ERROR(msg) qCCritical(bluetoothHandler) << "\033[31m" << msg << "\033[0m"
|
||||
#define LOG_DEBUG(msg) qCDebug(bluetoothHandler) << "\033[34m" << msg << "\033[0m"
|
||||
|
||||
BluetoothHandler::BluetoothHandler() {
|
||||
discoveryAgent = new QBluetoothDeviceDiscoveryAgent();
|
||||
discoveryAgent->setLowEnergyDiscoveryTimeout(5000);
|
||||
|
||||
connect(discoveryAgent, &QBluetoothDeviceDiscoveryAgent::deviceDiscovered, this, &BluetoothHandler::onDeviceDiscovered);
|
||||
connect(discoveryAgent, &QBluetoothDeviceDiscoveryAgent::finished, this, &BluetoothHandler::onDiscoveryFinished);
|
||||
discoveryAgent->start();
|
||||
LOG_INFO("BluetoothHandler initialized and started device discovery");
|
||||
}
|
||||
|
||||
void BluetoothHandler::connectToDevice(const QBluetoothDeviceInfo &device) {
|
||||
if (socket && socket->isOpen() && socket->peerAddress() == device.address()) {
|
||||
LOG_INFO("Already connected to the device: " << device.name());
|
||||
return;
|
||||
}
|
||||
|
||||
LOG_INFO("Connecting to device: " << device.name());
|
||||
QBluetoothSocket *localSocket = new QBluetoothSocket(QBluetoothServiceInfo::L2capProtocol);
|
||||
connect(localSocket, &QBluetoothSocket::connected, this, [this, localSocket]() {
|
||||
LOG_INFO("Connected to device, sending initial packets");
|
||||
discoveryAgent->stop();
|
||||
|
||||
QByteArray handshakePacket = QByteArray::fromHex("00000400010002000000000000000000");
|
||||
QByteArray setSpecificFeaturesPacket = QByteArray::fromHex("040004004d00ff00000000000000");
|
||||
QByteArray requestNotificationsPacket = QByteArray::fromHex("040004000f00ffffffffff");
|
||||
|
||||
qint64 bytesWritten = localSocket->write(handshakePacket);
|
||||
LOG_DEBUG("Handshake packet written: " << handshakePacket.toHex() << ", bytes written: " << bytesWritten);
|
||||
|
||||
QByteArray airpodsConnectedPacket = QByteArray::fromHex("000400010001");
|
||||
phoneSocket->write(airpodsConnectedPacket);
|
||||
LOG_DEBUG("AIRPODS_CONNECTED packet written: " << airpodsConnectedPacket.toHex());
|
||||
|
||||
connect(localSocket, &QBluetoothSocket::bytesWritten, this, [this, localSocket, setSpecificFeaturesPacket, requestNotificationsPacket](qint64 bytes) {
|
||||
LOG_INFO("Bytes written: " << bytes);
|
||||
if (bytes > 0) {
|
||||
static int step = 0;
|
||||
switch (step) {
|
||||
case 0:
|
||||
localSocket->write(setSpecificFeaturesPacket);
|
||||
LOG_DEBUG("Set specific features packet written: " << setSpecificFeaturesPacket.toHex());
|
||||
step++;
|
||||
break;
|
||||
case 1:
|
||||
localSocket->write(requestNotificationsPacket);
|
||||
LOG_DEBUG("Request notifications packet written: " << requestNotificationsPacket.toHex());
|
||||
step++;
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
connect(localSocket, &QBluetoothSocket::readyRead, this, [this, localSocket]() {
|
||||
QByteArray data = localSocket->readAll();
|
||||
LOG_DEBUG("Data received: " << data.toHex());
|
||||
parseData(data);
|
||||
relayPacketToPhone(data);
|
||||
});
|
||||
});
|
||||
|
||||
connect(localSocket, QOverload<QBluetoothSocket::SocketError>::of(&QBluetoothSocket::errorOccurred), this, [this, localSocket](QBluetoothSocket::SocketError error) {
|
||||
LOG_ERROR("Socket error: " << error << ", " << localSocket->errorString());
|
||||
});
|
||||
|
||||
localSocket->connectToService(device.address(), QBluetoothUuid("74ec2172-0bad-4d01-8f77-997b2be0722a"));
|
||||
socket = localSocket;
|
||||
connectedDeviceMacAddress = device.address().toString().replace(":", "_");
|
||||
}
|
||||
|
||||
void BluetoothHandler::parseData(const QByteArray &data) {
|
||||
LOG_DEBUG("Parsing data: " << data.toHex() << "Size: " << data.size());
|
||||
if (data.size() == 11 && data.startsWith(QByteArray::fromHex("0400040009000D"))) {
|
||||
int mode = data[7] - 1;
|
||||
LOG_INFO("Noise control mode: " << mode);
|
||||
if (mode >= 0 && mode <= 3) {
|
||||
emit noiseControlModeChanged(mode);
|
||||
} else {
|
||||
LOG_ERROR("Invalid noise control mode value received: " << mode);
|
||||
}
|
||||
} else if (data.size() == 8 && data.startsWith(QByteArray::fromHex("040004000600"))) {
|
||||
bool primaryInEar = data[6] == 0x00;
|
||||
bool secondaryInEar = data[7] == 0x00;
|
||||
QString earDetectionStatus = QString("Primary: %1, Secondary: %2")
|
||||
.arg(primaryInEar ? "In Ear" : "Out of Ear")
|
||||
.arg(secondaryInEar ? "In Ear" : "Out of Ear");
|
||||
LOG_INFO("Ear detection status: " << earDetectionStatus);
|
||||
emit earDetectionStatusChanged(earDetectionStatus);
|
||||
} else if (data.size() == 22 && data.startsWith(QByteArray::fromHex("040004000400"))) {
|
||||
int leftLevel = data[9];
|
||||
int rightLevel = data[14];
|
||||
int caseLevel = data[19];
|
||||
QString batteryStatus = QString("Left: %1%, Right: %2%, Case: %3%")
|
||||
.arg(leftLevel)
|
||||
.arg(rightLevel)
|
||||
.arg(caseLevel);
|
||||
LOG_INFO("Battery status: " << batteryStatus);
|
||||
emit batteryStatusChanged(batteryStatus);
|
||||
} else if (data.size() == 10 &&
|
||||
@@ -1,23 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#include <QBluetoothDeviceInfo>
|
||||
#include <QBluetoothSocket>
|
||||
#include <QBluetoothDeviceDiscoveryAgent>
|
||||
|
||||
class BluetoothHandler : public QObject {
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
BluetoothHandler();
|
||||
void connectToDevice(const QBluetoothDeviceInfo &device);
|
||||
void parseData(const QByteArray &data);
|
||||
|
||||
signals:
|
||||
void noiseControlModeChanged(int mode);
|
||||
void earDetectionStatusChanged(const QString &status);
|
||||
void batteryStatusChanged(const QString &status);
|
||||
|
||||
private:
|
||||
QBluetoothSocket *socket = nullptr;
|
||||
QBluetoothDeviceDiscoveryAgent *discoveryAgent;
|
||||
};
|
||||
@@ -10,9 +10,6 @@ qt_standard_project_setup(REQUIRES 6.5)
|
||||
|
||||
qt_add_executable(applinux
|
||||
main.cpp
|
||||
AirPodsTrayApp.cpp
|
||||
BluetoothHandler.cpp
|
||||
PacketDefinitions.cpp
|
||||
)
|
||||
|
||||
qt_add_qml_module(applinux
|
||||
|
||||
@@ -21,12 +21,14 @@ ApplicationWindow {
|
||||
text: "Battery Status: "
|
||||
id: batteryStatus
|
||||
objectName: "batteryStatus"
|
||||
color: "#ffffff"
|
||||
}
|
||||
|
||||
Text {
|
||||
text: "Ear Detection Status: "
|
||||
id: earDetectionStatus
|
||||
objectName: "earDetectionStatus"
|
||||
color: "#ffffff"
|
||||
}
|
||||
|
||||
ComboBox {
|
||||
|
||||
222
linux/main.cpp
222
linux/main.cpp
@@ -39,7 +39,6 @@ Q_LOGGING_CATEGORY(airpodsApp, "airpodsApp")
|
||||
|
||||
#define PHONE_MAC_ADDRESS "22:22:F5:BB:1C:A0"
|
||||
|
||||
// Define Manufacturer Specific Data Identifier
|
||||
#define MANUFACTURER_ID 0x1234
|
||||
#define MANUFACTURER_DATA "ALN_AirPods"
|
||||
|
||||
@@ -92,7 +91,7 @@ public:
|
||||
connect(trayIcon, &QSystemTrayIcon::activated, this, &AirPodsTrayApp::onTrayIconActivated);
|
||||
|
||||
discoveryAgent = new QBluetoothDeviceDiscoveryAgent();
|
||||
discoveryAgent->setLowEnergyDiscoveryTimeout(5000);
|
||||
discoveryAgent->setLowEnergyDiscoveryTimeout(15000);
|
||||
|
||||
connect(discoveryAgent, &QBluetoothDeviceDiscoveryAgent::deviceDiscovered, this, &AirPodsTrayApp::onDeviceDiscovered);
|
||||
connect(discoveryAgent, &QBluetoothDeviceDiscoveryAgent::finished, this, &AirPodsTrayApp::onDiscoveryFinished);
|
||||
@@ -114,12 +113,10 @@ public:
|
||||
initializeMprisInterface();
|
||||
connect(phoneSocket, &QBluetoothSocket::readyRead, this, &AirPodsTrayApp::onPhoneDataReceived);
|
||||
|
||||
// After starting discovery, check if service record exists
|
||||
QDBusInterface iface("org.bluez", "/org/bluez", "org.bluez.Adapter1");
|
||||
QDBusReply<QVariant> reply = iface.call("GetServiceRecords", QString::fromUtf8("74ec2172-0bad-4d01-8f77-997b2be0722a"));
|
||||
if (reply.isValid()) {
|
||||
LOG_INFO("Service record found, proceeding with connection");
|
||||
// Proceed with existing connection logic
|
||||
} else {
|
||||
LOG_WARN("Service record not found, waiting for BLE broadcast");
|
||||
}
|
||||
@@ -236,7 +233,7 @@ public slots:
|
||||
bool secondaryInEar = parts[1].contains("In Ear");
|
||||
|
||||
if (primaryInEar && secondaryInEar) {
|
||||
if (wasPausedByApp) {
|
||||
if (wasPausedByApp && isActiveOutputDeviceAirPods()) {
|
||||
QProcess::execute("playerctl", QStringList() << "play");
|
||||
LOG_INFO("Resumed playback via Playerctl");
|
||||
wasPausedByApp = false;
|
||||
@@ -245,15 +242,17 @@ public slots:
|
||||
activateA2dpProfile();
|
||||
} else {
|
||||
LOG_INFO("At least one AirPod is out of ear");
|
||||
QProcess process;
|
||||
process.start("playerctl", QStringList() << "status");
|
||||
process.waitForFinished();
|
||||
QString playbackStatus = process.readAllStandardOutput().trimmed();
|
||||
LOG_DEBUG("Playback status: " << playbackStatus);
|
||||
if (playbackStatus == "Playing") {
|
||||
QProcess::execute("playerctl", QStringList() << "pause");
|
||||
LOG_INFO("Paused playback via Playerctl");
|
||||
wasPausedByApp = true;
|
||||
if (isActiveOutputDeviceAirPods()) {
|
||||
QProcess process;
|
||||
process.start("playerctl", QStringList() << "status");
|
||||
process.waitForFinished();
|
||||
QString playbackStatus = process.readAllStandardOutput().trimmed();
|
||||
LOG_DEBUG("Playback status: " << playbackStatus);
|
||||
if (playbackStatus == "Playing") {
|
||||
QProcess::execute("playerctl", QStringList() << "pause");
|
||||
LOG_INFO("Paused playback via Playerctl");
|
||||
wasPausedByApp = true;
|
||||
}
|
||||
}
|
||||
if (!primaryInEar && !secondaryInEar) {
|
||||
removeAudioOutputDevice();
|
||||
@@ -308,7 +307,6 @@ public slots:
|
||||
QByteArray manufacturerData = device.manufacturerData(MANUFACTURER_ID);
|
||||
if (manufacturerData.startsWith(MANUFACTURER_DATA)) {
|
||||
LOG_INFO("Detected AirPods via BLE manufacturer data");
|
||||
// Initiate RFComm connection
|
||||
connectToDevice(device.address().toString());
|
||||
}
|
||||
LOG_INFO("Device discovered: " << device.name() << " (" << device.address().toString() << ")");
|
||||
@@ -320,7 +318,6 @@ public slots:
|
||||
|
||||
void onDiscoveryFinished() {
|
||||
LOG_INFO("Device discovery finished");
|
||||
// Restart discovery to continuously listen for broadcasts
|
||||
discoveryAgent->start();
|
||||
const QList<QBluetoothDeviceInfo> discoveredDevices = discoveryAgent->discoveredDevices();
|
||||
for (const QBluetoothDeviceInfo &device : discoveredDevices) {
|
||||
@@ -359,79 +356,70 @@ public slots:
|
||||
LOG_INFO("Already connected to the device: " << device.name());
|
||||
return;
|
||||
}
|
||||
|
||||
LOG_INFO("Checking connection status with phone before connecting to device: " << device.name());
|
||||
QByteArray connectionStatusRequest = QByteArray::fromHex("00020003");
|
||||
if (phoneSocket && phoneSocket->isOpen()) {
|
||||
phoneSocket->write(connectionStatusRequest);
|
||||
LOG_DEBUG("Connection status request packet written: " << connectionStatusRequest.toHex());
|
||||
connect(phoneSocket, &QBluetoothSocket::readyRead, this, [this, device]() {
|
||||
QByteArray data = phoneSocket->read(4);
|
||||
LOG_DEBUG("Data received from phone: " << data.toHex());
|
||||
if (data == QByteArray::fromHex("00010001")) {
|
||||
LOG_INFO("AirPods are already connected");
|
||||
disconnect(phoneSocket, &QBluetoothSocket::readyRead, nullptr, nullptr);
|
||||
} else if (data == QByteArray::fromHex("00010000")) {
|
||||
LOG_INFO("AirPods are disconnected, proceeding with connection");
|
||||
disconnect(phoneSocket, &QBluetoothSocket::readyRead, nullptr, nullptr);
|
||||
|
||||
QBluetoothSocket *localSocket = new QBluetoothSocket(QBluetoothServiceInfo::L2capProtocol);
|
||||
connect(localSocket, &QBluetoothSocket::connected, this, [this, localSocket]() {
|
||||
LOG_INFO("Connected to device, sending initial packets");
|
||||
discoveryAgent->stop();
|
||||
|
||||
QByteArray handshakePacket = QByteArray::fromHex("00000400010002000000000000000000");
|
||||
QByteArray setSpecificFeaturesPacket = QByteArray::fromHex("040004004d00ff00000000000000");
|
||||
QByteArray requestNotificationsPacket = QByteArray::fromHex("040004000f00ffffffffff");
|
||||
|
||||
qint64 bytesWritten = localSocket->write(handshakePacket);
|
||||
LOG_DEBUG("Handshake packet written: " << handshakePacket.toHex() << ", bytes written: " << bytesWritten);
|
||||
|
||||
QByteArray airpodsConnectedPacket = QByteArray::fromHex("000400010001");
|
||||
phoneSocket->write(airpodsConnectedPacket);
|
||||
LOG_DEBUG("AIRPODS_CONNECTED packet written: " << airpodsConnectedPacket.toHex());
|
||||
|
||||
connect(localSocket, &QBluetoothSocket::bytesWritten, this, [this, localSocket, setSpecificFeaturesPacket, requestNotificationsPacket](qint64 bytes) {
|
||||
LOG_INFO("Bytes written: " << bytes);
|
||||
if (bytes > 0) {
|
||||
static int step = 0;
|
||||
switch (step) {
|
||||
case 0:
|
||||
localSocket->write(setSpecificFeaturesPacket);
|
||||
LOG_DEBUG("Set specific features packet written: " << setSpecificFeaturesPacket.toHex());
|
||||
step++;
|
||||
break;
|
||||
case 1:
|
||||
localSocket->write(requestNotificationsPacket);
|
||||
LOG_DEBUG("Request notifications packet written: " << requestNotificationsPacket.toHex());
|
||||
step++;
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
connect(localSocket, &QBluetoothSocket::readyRead, this, [this, localSocket]() {
|
||||
QByteArray data = localSocket->readAll();
|
||||
LOG_DEBUG("Data received: " << data.toHex());
|
||||
parseData(data);
|
||||
relayPacketToPhone(data);
|
||||
});
|
||||
});
|
||||
|
||||
connect(localSocket, QOverload<QBluetoothSocket::SocketError>::of(&QBluetoothSocket::errorOccurred), this, [this, localSocket](QBluetoothSocket::SocketError error) {
|
||||
LOG_ERROR("Socket error: " << error << ", " << localSocket->errorString());
|
||||
});
|
||||
|
||||
localSocket->connectToService(device.address(), QBluetoothUuid("74ec2172-0bad-4d01-8f77-997b2be0722a"));
|
||||
socket = localSocket;
|
||||
connectedDeviceMacAddress = device.address().toString().replace(":", "_");
|
||||
|
||||
LOG_INFO("Connecting to device: " << device.name());
|
||||
QBluetoothSocket *localSocket = new QBluetoothSocket(QBluetoothServiceInfo::RfcommProtocol);
|
||||
connect(localSocket, &QBluetoothSocket::connected, this, [this, localSocket]() {
|
||||
LOG_INFO("Connected to device, sending initial packets");
|
||||
discoveryAgent->stop();
|
||||
|
||||
QByteArray handshakePacket = QByteArray::fromHex("00000400010002000000000000000000");
|
||||
QByteArray setSpecificFeaturesPacket = QByteArray::fromHex("040004004d00ff00000000000000");
|
||||
QByteArray requestNotificationsPacket = QByteArray::fromHex("040004000f00ffffffffff");
|
||||
|
||||
qint64 bytesWritten = localSocket->write(handshakePacket);
|
||||
LOG_DEBUG("Handshake packet written: " << handshakePacket.toHex() << ", bytes written: " << bytesWritten);
|
||||
localSocket->write(setSpecificFeaturesPacket);
|
||||
LOG_DEBUG("Set specific features packet written: " << setSpecificFeaturesPacket.toHex());
|
||||
localSocket->write(requestNotificationsPacket);
|
||||
LOG_DEBUG("Request notifications packet written: " << requestNotificationsPacket.toHex());
|
||||
connect(localSocket, &QBluetoothSocket::bytesWritten, this, [this, localSocket, setSpecificFeaturesPacket, requestNotificationsPacket](qint64 bytes) {
|
||||
LOG_INFO("Bytes written: " << bytes);
|
||||
if (bytes > 0) {
|
||||
static int step = 0;
|
||||
switch (step) {
|
||||
case 0:
|
||||
localSocket->write(setSpecificFeaturesPacket);
|
||||
LOG_DEBUG("Set specific features packet written: " << setSpecificFeaturesPacket.toHex());
|
||||
step++;
|
||||
break;
|
||||
case 1:
|
||||
localSocket->write(requestNotificationsPacket);
|
||||
LOG_DEBUG("Request notifications packet written: " << requestNotificationsPacket.toHex());
|
||||
step++;
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
} else {
|
||||
LOG_ERROR("Phone socket is not open, cannot send connection status request");
|
||||
}
|
||||
|
||||
connect(localSocket, &QBluetoothSocket::readyRead, this, [this, localSocket]() {
|
||||
QByteArray data = localSocket->readAll();
|
||||
LOG_DEBUG("Data received: " << data.toHex());
|
||||
QMetaObject::invokeMethod(this, "parseData", Qt::QueuedConnection, Q_ARG(QByteArray, data));
|
||||
QMetaObject::invokeMethod(this, "relayPacketToPhone", Qt::QueuedConnection, Q_ARG(QByteArray, data));
|
||||
});
|
||||
|
||||
QTimer::singleShot(500, this, [localSocket, setSpecificFeaturesPacket, requestNotificationsPacket]() {
|
||||
if (localSocket->isOpen()) {
|
||||
localSocket->write(setSpecificFeaturesPacket);
|
||||
LOG_DEBUG("Resent set specific features packet: " << setSpecificFeaturesPacket.toHex());
|
||||
localSocket->write(requestNotificationsPacket);
|
||||
LOG_DEBUG("Resent request notifications packet: " << requestNotificationsPacket.toHex());
|
||||
} else {
|
||||
LOG_WARN("Socket is not open, cannot resend packets");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
connect(localSocket, QOverload<QBluetoothSocket::SocketError>::of(&QBluetoothSocket::errorOccurred), this, [this, localSocket](QBluetoothSocket::SocketError error) {
|
||||
LOG_ERROR("Socket error: " << error << ", " << localSocket->errorString());
|
||||
});
|
||||
|
||||
localSocket->connectToService(device.address(), QBluetoothUuid("74ec2172-0bad-4d01-8f77-997b2be0722a"));
|
||||
socket = localSocket;
|
||||
connectedDeviceMacAddress = device.address().toString().replace(":", "_");
|
||||
}
|
||||
|
||||
|
||||
void parseData(const QByteArray &data) {
|
||||
LOG_DEBUG("Parsing data: " << data.toHex() << "Size: " << data.size());
|
||||
if (data.size() == 11 && data.startsWith(QByteArray::fromHex("0400040009000D"))) {
|
||||
@@ -474,7 +462,7 @@ public slots:
|
||||
LOG_INFO("Conversational awareness: " << (lowered ? "enabled" : "disabled"));
|
||||
|
||||
if (lowered) {
|
||||
if (initialVolume == -1) {
|
||||
if (initialVolume == -1 && isActiveOutputDeviceAirPods()) {
|
||||
QProcess process;
|
||||
process.start("pactl", QStringList() << "get-sink-volume" << "@DEFAULT_SINK@");
|
||||
process.waitForFinished();
|
||||
@@ -492,7 +480,7 @@ public slots:
|
||||
QProcess::execute("pactl", QStringList() << "set-sink-volume" << "@DEFAULT_SINK@" << QString::number(initialVolume * 0.20) + "%");
|
||||
LOG_INFO("Volume lowered to 0.20 of initial which is " << initialVolume * 0.20 << "%");
|
||||
} else {
|
||||
if (initialVolume != -1) {
|
||||
if (initialVolume != -1 && isActiveOutputDeviceAirPods()) {
|
||||
QProcess::execute("pactl", QStringList() << "set-sink-volume" << "@DEFAULT_SINK@" << QString::number(initialVolume) + "%");
|
||||
LOG_INFO("Volume restored to " << initialVolume << "%");
|
||||
initialVolume = -1;
|
||||
@@ -500,6 +488,15 @@ public slots:
|
||||
}
|
||||
}
|
||||
|
||||
bool isActiveOutputDeviceAirPods() {
|
||||
QProcess process;
|
||||
process.start("pactl", QStringList() << "get-default-sink");
|
||||
process.waitForFinished();
|
||||
QString output = process.readAllStandardOutput().trimmed();
|
||||
LOG_DEBUG("Default sink: " << output);
|
||||
return output.contains("bluez_card." + connectedDeviceMacAddress);
|
||||
}
|
||||
|
||||
void initializeMprisInterface() {
|
||||
QStringList services = QDBusConnection::sessionBus().interface()->registeredServiceNames();
|
||||
QString mprisService;
|
||||
@@ -560,7 +557,7 @@ public slots:
|
||||
|
||||
void handlePhonePacket(const QByteArray &packet) {
|
||||
if (packet.startsWith(QByteArray::fromHex("00040001"))) {
|
||||
QByteArray airpodsPacket = packet.mid(4); // Remove the header
|
||||
QByteArray airpodsPacket = packet.mid(4);
|
||||
if (socket && socket->isOpen()) {
|
||||
socket->write(airpodsPacket);
|
||||
LOG_DEBUG("Relayed packet to AirPods: " << airpodsPacket.toHex());
|
||||
@@ -569,15 +566,24 @@ public slots:
|
||||
}
|
||||
} else if (packet.startsWith(QByteArray::fromHex("00010001"))) {
|
||||
LOG_INFO("AirPods connected");
|
||||
// Handle AirPods connected
|
||||
} else if (packet.startsWith(QByteArray::fromHex("00010000"))) {
|
||||
LOG_INFO("AirPods disconnected");
|
||||
// Handle AirPods disconnected
|
||||
} else if (packet.startsWith(QByteArray::fromHex("00020003"))) {
|
||||
LOG_INFO("Connection status request received");
|
||||
QByteArray response = (socket && socket->isOpen()) ? QByteArray::fromHex("00010001") : QByteArray::fromHex("00010000");
|
||||
phoneSocket->write(response);
|
||||
LOG_DEBUG("Sent connection status response: " << response.toHex());
|
||||
} else if (packet.startsWith(QByteArray::fromHex("00020000"))) {
|
||||
LOG_INFO("Disconnect request received");
|
||||
if (socket && socket->isOpen()) {
|
||||
socket->close();
|
||||
LOG_INFO("Disconnected from AirPods");
|
||||
QProcess process;
|
||||
process.start("bluetoothctl", QStringList() << "disconnect" << connectedDeviceMacAddress.replace("_", ":"));
|
||||
process.waitForFinished();
|
||||
QString output = process.readAllStandardOutput().trimmed();
|
||||
LOG_INFO("Bluetoothctl output: " << output);
|
||||
}
|
||||
} else {
|
||||
if (socket && socket->isOpen()) {
|
||||
socket->write(packet);
|
||||
@@ -591,7 +597,37 @@ public slots:
|
||||
void onPhoneDataReceived() {
|
||||
QByteArray data = phoneSocket->readAll();
|
||||
LOG_DEBUG("Data received from phone: " << data.toHex());
|
||||
handlePhonePacket(data);
|
||||
QMetaObject::invokeMethod(this, "handlePhonePacket", Qt::QueuedConnection, Q_ARG(QByteArray, data));
|
||||
}
|
||||
|
||||
public: void followMediaChanges() {
|
||||
QProcess *playerctlProcess = new QProcess(this);
|
||||
connect(playerctlProcess, &QProcess::readyReadStandardOutput, this, [this, playerctlProcess]() {
|
||||
QString output = playerctlProcess->readAllStandardOutput().trimmed();
|
||||
LOG_DEBUG("Playerctl output: " << output);
|
||||
if (output == "Playing" && isPhoneConnected()) {
|
||||
LOG_INFO("Media started playing, connecting to AirPods");
|
||||
connectToAirPods();
|
||||
}
|
||||
});
|
||||
playerctlProcess->start("playerctl", QStringList() << "metadata" << "--follow" << "status");
|
||||
}
|
||||
|
||||
bool isPhoneConnected() {
|
||||
return phoneSocket && phoneSocket->isOpen();
|
||||
}
|
||||
|
||||
void connectToAirPods() {
|
||||
QBluetoothLocalDevice localDevice;
|
||||
const QList<QBluetoothAddress> connectedDevices = localDevice.connectedDevices();
|
||||
for (const QBluetoothAddress &address : connectedDevices) {
|
||||
QBluetoothDeviceInfo device(address, "", 0);
|
||||
if (device.serviceUuids().contains(QBluetoothUuid("74ec2172-0bad-4d01-8f77-997b2be0722a"))) {
|
||||
connectToDevice(device);
|
||||
return;
|
||||
}
|
||||
}
|
||||
LOG_WARN("AirPods not found among connected devices");
|
||||
}
|
||||
|
||||
signals:
|
||||
@@ -673,6 +709,8 @@ int main(int argc, char *argv[]) {
|
||||
}
|
||||
});
|
||||
|
||||
trayApp.followMediaChanges();
|
||||
|
||||
return app.exec();
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user