mirror of
https://github.com/kavishdevar/librepods.git
synced 2026-04-28 17:17:54 +00:00
some progress on cross-device, and new dynamic island thingy!
This commit is contained in:
@@ -23,6 +23,7 @@
|
|||||||
android:usesPermissionFlags="neverForLocation"
|
android:usesPermissionFlags="neverForLocation"
|
||||||
tools:ignore="UnusedAttribute" />
|
tools:ignore="UnusedAttribute" />
|
||||||
<uses-permission android:name="android.permission.BLUETOOTH_ADVERTISE" />
|
<uses-permission android:name="android.permission.BLUETOOTH_ADVERTISE" />
|
||||||
|
<uses-permission android:name="android.permission.READ_PHONE_STATE" />
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:allowBackup="true"
|
android:allowBackup="true"
|
||||||
|
|||||||
@@ -135,7 +135,8 @@ fun Main() {
|
|||||||
permissions = listOf(
|
permissions = listOf(
|
||||||
"android.permission.BLUETOOTH_CONNECT",
|
"android.permission.BLUETOOTH_CONNECT",
|
||||||
"android.permission.BLUETOOTH_SCAN",
|
"android.permission.BLUETOOTH_SCAN",
|
||||||
"android.permission.POST_NOTIFICATIONS"
|
"android.permission.POST_NOTIFICATIONS",
|
||||||
|
"android.permission.READ_PHONE_STATE"
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
val airPodsService = remember { mutableStateOf<AirPodsService?>(null) }
|
val airPodsService = remember { mutableStateOf<AirPodsService?>(null) }
|
||||||
@@ -308,7 +309,6 @@ fun Main() {
|
|||||||
isConnected.value = true
|
isConnected.value = true
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Permission is not granted, request it
|
|
||||||
Column (
|
Column (
|
||||||
modifier = Modifier.padding(24.dp),
|
modifier = Modifier.padding(24.dp),
|
||||||
){
|
){
|
||||||
|
|||||||
@@ -50,6 +50,8 @@ import android.os.Handler
|
|||||||
import android.os.IBinder
|
import android.os.IBinder
|
||||||
import android.os.Looper
|
import android.os.Looper
|
||||||
import android.os.ParcelUuid
|
import android.os.ParcelUuid
|
||||||
|
import android.telephony.PhoneStateListener
|
||||||
|
import android.telephony.TelephonyManager
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import android.util.TypedValue
|
import android.util.TypedValue
|
||||||
import android.view.View
|
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.CrossDevice
|
||||||
import me.kavishdevar.aln.utils.CrossDevicePackets
|
import me.kavishdevar.aln.utils.CrossDevicePackets
|
||||||
import me.kavishdevar.aln.utils.Enums
|
import me.kavishdevar.aln.utils.Enums
|
||||||
|
import me.kavishdevar.aln.utils.IslandWindow
|
||||||
import me.kavishdevar.aln.utils.LongPressPackets
|
import me.kavishdevar.aln.utils.LongPressPackets
|
||||||
import me.kavishdevar.aln.utils.MediaController
|
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.BatteryWidget
|
||||||
import me.kavishdevar.aln.widgets.NoiseControlWidget
|
import me.kavishdevar.aln.widgets.NoiseControlWidget
|
||||||
import org.lsposed.hiddenapibypass.HiddenApiBypass
|
import org.lsposed.hiddenapibypass.HiddenApiBypass
|
||||||
@@ -114,7 +117,7 @@ object ServiceManager {
|
|||||||
|
|
||||||
// @Suppress("unused")
|
// @Suppress("unused")
|
||||||
class AirPodsService : Service() {
|
class AirPodsService : Service() {
|
||||||
private var macAddress = ""
|
var macAddress = ""
|
||||||
|
|
||||||
inner class LocalBinder : Binder() {
|
inner class LocalBinder : Binder() {
|
||||||
fun getService(): AirPodsService = this@AirPodsService
|
fun getService(): AirPodsService = this@AirPodsService
|
||||||
@@ -126,6 +129,9 @@ class AirPodsService : Service() {
|
|||||||
private val _packetLogsFlow = MutableStateFlow<Set<String>>(emptySet())
|
private val _packetLogsFlow = MutableStateFlow<Set<String>>(emptySet())
|
||||||
val packetLogsFlow: StateFlow<Set<String>> get() = _packetLogsFlow
|
val packetLogsFlow: StateFlow<Set<String>> get() = _packetLogsFlow
|
||||||
|
|
||||||
|
private lateinit var telephonyManager: TelephonyManager
|
||||||
|
private lateinit var phoneStateListener: PhoneStateListener
|
||||||
|
|
||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
super.onCreate()
|
super.onCreate()
|
||||||
sharedPreferencesLogs = getSharedPreferences("packet_logs", MODE_PRIVATE)
|
sharedPreferencesLogs = getSharedPreferences("packet_logs", MODE_PRIVATE)
|
||||||
@@ -166,10 +172,25 @@ class AirPodsService : Service() {
|
|||||||
if (popupShown) {
|
if (popupShown) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
val window = Window(service.applicationContext)
|
val popupWindow = PopupWindow(service.applicationContext)
|
||||||
window.open(name, batteryNotification)
|
popupWindow.open(name, batteryNotification)
|
||||||
popupShown = true
|
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")
|
@Suppress("ClassName")
|
||||||
private object bluetoothReceiver : BroadcastReceiver() {
|
private object bluetoothReceiver : BroadcastReceiver() {
|
||||||
@@ -220,23 +241,7 @@ class AirPodsService : Service() {
|
|||||||
object BatteryChangedIntentReceiver : BroadcastReceiver() {
|
object BatteryChangedIntentReceiver : BroadcastReceiver() {
|
||||||
override fun onReceive(context: Context?, intent: Intent) {
|
override fun onReceive(context: Context?, intent: Intent) {
|
||||||
if (intent.action == Intent.ACTION_BATTERY_CHANGED) {
|
if (intent.action == Intent.ACTION_BATTERY_CHANGED) {
|
||||||
val level = intent.getIntExtra("level", 0)
|
ServiceManager.getService()?.updateBatteryWidget()
|
||||||
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)
|
|
||||||
}
|
|
||||||
} else if (intent.action == AirPodsNotifications.DISCONNECT_RECEIVERS) {
|
} else if (intent.action == AirPodsNotifications.DISCONNECT_RECEIVERS) {
|
||||||
try {
|
try {
|
||||||
context?.unregisterReceiver(this)
|
context?.unregisterReceiver(this)
|
||||||
@@ -568,13 +573,55 @@ class AirPodsService : Service() {
|
|||||||
Log.d("AirPodsService", "Service started")
|
Log.d("AirPodsService", "Service started")
|
||||||
ServiceManager.setService(this)
|
ServiceManager.setService(this)
|
||||||
startForegroundNotification()
|
startForegroundNotification()
|
||||||
|
val audioManager =
|
||||||
|
this@AirPodsService.getSystemService(AUDIO_SERVICE) as AudioManager
|
||||||
|
MediaController.initialize(
|
||||||
|
audioManager,
|
||||||
|
this@AirPodsService.getSharedPreferences(
|
||||||
|
"settings",
|
||||||
|
MODE_PRIVATE
|
||||||
|
)
|
||||||
|
)
|
||||||
Log.d("AirPodsService", "Initializing CrossDevice")
|
Log.d("AirPodsService", "Initializing CrossDevice")
|
||||||
CrossDevice.init(this)
|
CoroutineScope(Dispatchers.IO).launch {
|
||||||
Log.d("AirPodsService", "CrossDevice initialized")
|
CrossDevice.init(this@AirPodsService)
|
||||||
|
Log.d("AirPodsService", "CrossDevice initialized")
|
||||||
|
}
|
||||||
|
|
||||||
sharedPreferences = getSharedPreferences("settings", MODE_PRIVATE)
|
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 {
|
val serviceIntentFilter = IntentFilter().apply {
|
||||||
addAction("android.bluetooth.device.action.ACL_CONNECTED")
|
addAction("android.bluetooth.device.action.ACL_CONNECTED")
|
||||||
addAction("android.bluetooth.device.action.ACL_DISCONNECTED")
|
addAction("android.bluetooth.device.action.ACL_DISCONNECTED")
|
||||||
@@ -605,13 +652,16 @@ class AirPodsService : Service() {
|
|||||||
putString("name", name)
|
putString("name", name)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Log.d("AirPodsQuickSwitchServices", CrossDevice.isAvailable.toString())
|
Log.d("AirPodsCrossDevice", CrossDevice.isAvailable.toString())
|
||||||
if (!CrossDevice.checkAirPodsConnectionStatus()) {
|
if (!CrossDevice.isAvailable) {
|
||||||
Log.d("AirPodsService", "$name connected")
|
Log.d("AirPodsService", "$name connected")
|
||||||
showPopup(this@AirPodsService, name.toString())
|
showPopup(this@AirPodsService, name.toString())
|
||||||
connectToSocket(device!!)
|
connectToSocket(device!!)
|
||||||
isConnectedLocally = true
|
isConnectedLocally = true
|
||||||
macAddress = device!!.address
|
macAddress = device!!.address
|
||||||
|
sharedPreferences.edit {
|
||||||
|
putString("mac_address", macAddress)
|
||||||
|
}
|
||||||
updateNotificationContent(
|
updateNotificationContent(
|
||||||
true,
|
true,
|
||||||
name.toString(),
|
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 {
|
val deviceIntentFilter = IntentFilter().apply {
|
||||||
addAction(AirPodsNotifications.Companion.AIRPODS_CONNECTION_DETECTED)
|
addAction(AirPodsNotifications.Companion.AIRPODS_CONNECTION_DETECTED)
|
||||||
@@ -641,11 +714,6 @@ class AirPodsService : Service() {
|
|||||||
registerReceiver(bluetoothReceiver, serviceIntentFilter)
|
registerReceiver(bluetoothReceiver, serviceIntentFilter)
|
||||||
}
|
}
|
||||||
|
|
||||||
widgetMobileBatteryEnabled = getSharedPreferences("settings", MODE_PRIVATE).getBoolean(
|
|
||||||
"show_phone_battery_in_widget",
|
|
||||||
true
|
|
||||||
)
|
|
||||||
|
|
||||||
val bluetoothAdapter = getSystemService(BluetoothManager::class.java).adapter
|
val bluetoothAdapter = getSystemService(BluetoothManager::class.java).adapter
|
||||||
if (bluetoothAdapter.isEnabled) {
|
if (bluetoothAdapter.isEnabled) {
|
||||||
CoroutineScope(Dispatchers.IO).launch {
|
CoroutineScope(Dispatchers.IO).launch {
|
||||||
@@ -682,8 +750,12 @@ class AirPodsService : Service() {
|
|||||||
if (profile == BluetoothProfile.A2DP) {
|
if (profile == BluetoothProfile.A2DP) {
|
||||||
val connectedDevices = proxy.connectedDevices
|
val connectedDevices = proxy.connectedDevices
|
||||||
if (connectedDevices.isNotEmpty()) {
|
if (connectedDevices.isNotEmpty()) {
|
||||||
if (!CrossDevice.checkAirPodsConnectionStatus()) {
|
if (!CrossDevice.isAvailable) {
|
||||||
connectToSocket(device)
|
connectToSocket(device)
|
||||||
|
macAddress = device.address
|
||||||
|
sharedPreferences.edit {
|
||||||
|
putString("mac_address", macAddress)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
this@AirPodsService.sendBroadcast(
|
this@AirPodsService.sendBroadcast(
|
||||||
Intent(AirPodsNotifications.Companion.AIRPODS_CONNECTED)
|
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")
|
@SuppressLint("MissingPermission", "UnspecifiedRegisterReceiverFlag")
|
||||||
fun connectToSocket(device: BluetoothDevice) {
|
fun connectToSocket(device: BluetoothDevice) {
|
||||||
HiddenApiBypass.addHiddenApiExemptions("Landroid/bluetooth/BluetoothSocket;")
|
HiddenApiBypass.addHiddenApiExemptions("Landroid/bluetooth/BluetoothSocket;")
|
||||||
val uuid: ParcelUuid = ParcelUuid.fromString("74ec2172-0bad-4d01-8f77-997b2be0722a")
|
val uuid: ParcelUuid = ParcelUuid.fromString("74ec2172-0bad-4d01-8f77-997b2be0722a")
|
||||||
|
|
||||||
if (isConnectedLocally != true) {
|
if (isConnectedLocally != true && !CrossDevice.isAvailable) {
|
||||||
try {
|
try {
|
||||||
socket = HiddenApiBypass.newInstance(
|
socket = HiddenApiBypass.newInstance(
|
||||||
BluetoothSocket::class.java,
|
BluetoothSocket::class.java,
|
||||||
@@ -799,15 +886,6 @@ class AirPodsService : Service() {
|
|||||||
)
|
)
|
||||||
while (socket.isConnected == true) {
|
while (socket.isConnected == true) {
|
||||||
socket.let {
|
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 buffer = ByteArray(1024)
|
||||||
val bytesRead = it.inputStream.read(buffer)
|
val bytesRead = it.inputStream.read(buffer)
|
||||||
var data: ByteArray = byteArrayOf()
|
var data: ByteArray = byteArrayOf()
|
||||||
@@ -852,11 +930,16 @@ class AirPodsService : Service() {
|
|||||||
} else {
|
} else {
|
||||||
data[0] == 0x00.toByte() && data[1] == 0x00.toByte()
|
data[0] == 0x00.toByte() && data[1] == 0x00.toByte()
|
||||||
}
|
}
|
||||||
|
|
||||||
val newInEarData = listOf(
|
val newInEarData = listOf(
|
||||||
data[0] == 0x00.toByte(),
|
data[0] == 0x00.toByte(),
|
||||||
data[1] == 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(
|
if (newInEarData.contains(true) && inEarData == listOf(
|
||||||
false,
|
false,
|
||||||
false
|
false
|
||||||
@@ -1296,6 +1379,9 @@ class AirPodsService : Service() {
|
|||||||
e.printStackTrace()
|
e.printStackTrace()
|
||||||
} finally {
|
} finally {
|
||||||
bluetoothAdapter.closeProfileProxy(BluetoothProfile.A2DP, proxy)
|
bluetoothAdapter.closeProfileProxy(BluetoothProfile.A2DP, proxy)
|
||||||
|
if (MediaController.pausedForCrossDevice) {
|
||||||
|
MediaController.sendPlay()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1521,6 +1607,7 @@ class AirPodsService : Service() {
|
|||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
e.printStackTrace()
|
e.printStackTrace()
|
||||||
}
|
}
|
||||||
|
telephonyManager.listen(phoneStateListener, PhoneStateListener.LISTEN_NONE)
|
||||||
super.onDestroy()
|
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
|
package me.kavishdevar.aln.utils
|
||||||
|
|
||||||
import android.annotation.SuppressLint
|
import android.annotation.SuppressLint
|
||||||
@@ -10,6 +29,7 @@ import android.bluetooth.le.AdvertiseData
|
|||||||
import android.bluetooth.le.AdvertiseSettings
|
import android.bluetooth.le.AdvertiseSettings
|
||||||
import android.bluetooth.le.BluetoothLeAdvertiser
|
import android.bluetooth.le.BluetoothLeAdvertiser
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
import android.content.SharedPreferences
|
import android.content.SharedPreferences
|
||||||
import android.os.ParcelUuid
|
import android.os.ParcelUuid
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
@@ -44,25 +64,28 @@ object CrossDevice {
|
|||||||
var batteryBytes: ByteArray = byteArrayOf()
|
var batteryBytes: ByteArray = byteArrayOf()
|
||||||
var ancBytes: ByteArray = byteArrayOf()
|
var ancBytes: ByteArray = byteArrayOf()
|
||||||
private lateinit var sharedPreferences: SharedPreferences
|
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")
|
@SuppressLint("MissingPermission")
|
||||||
fun init(context: Context) {
|
fun init(context: Context) {
|
||||||
Log.d("AirPodsQuickSwitchService", "Initializing CrossDevice")
|
CoroutineScope(Dispatchers.IO).launch {
|
||||||
sharedPreferences = context.getSharedPreferences("packet_logs", Context.MODE_PRIVATE)
|
Log.d("CrossDevice", "Initializing CrossDevice")
|
||||||
sharedPreferences.edit().putBoolean("CrossDeviceIsAvailable", false).apply()
|
sharedPreferences = context.getSharedPreferences("packet_logs", Context.MODE_PRIVATE)
|
||||||
this.bluetoothAdapter = context.getSystemService(BluetoothManager::class.java).adapter
|
sharedPreferences.edit().putBoolean("CrossDeviceIsAvailable", false).apply()
|
||||||
this.bluetoothLeAdvertiser = bluetoothAdapter.bluetoothLeAdvertiser
|
this@CrossDevice.bluetoothAdapter = context.getSystemService(BluetoothManager::class.java).adapter
|
||||||
startAdvertising()
|
this@CrossDevice.bluetoothLeAdvertiser = bluetoothAdapter.bluetoothLeAdvertiser
|
||||||
startServer()
|
startAdvertising()
|
||||||
initialized = true
|
startServer()
|
||||||
|
initialized = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@SuppressLint("MissingPermission")
|
@SuppressLint("MissingPermission")
|
||||||
fun startServer() {
|
private fun startServer() {
|
||||||
serverSocket = bluetoothAdapter.listenUsingRfcommWithServiceRecord("ALNCrossDevice", uuid)
|
|
||||||
Log.d("AirPodsQuickSwitchService", "Server started")
|
|
||||||
CoroutineScope(Dispatchers.IO).launch {
|
CoroutineScope(Dispatchers.IO).launch {
|
||||||
|
serverSocket = bluetoothAdapter.listenUsingRfcommWithServiceRecord("ALNCrossDevice", uuid)
|
||||||
|
Log.d("CrossDevice", "Server started")
|
||||||
while (serverSocket != null) {
|
while (serverSocket != null) {
|
||||||
try {
|
try {
|
||||||
val socket = serverSocket!!.accept()
|
val socket = serverSocket!!.accept()
|
||||||
@@ -76,29 +99,31 @@ object CrossDevice {
|
|||||||
|
|
||||||
@SuppressLint("MissingPermission")
|
@SuppressLint("MissingPermission")
|
||||||
private fun startAdvertising() {
|
private fun startAdvertising() {
|
||||||
val settings = AdvertiseSettings.Builder()
|
CoroutineScope(Dispatchers.IO).launch {
|
||||||
.setAdvertiseMode(AdvertiseSettings.ADVERTISE_MODE_LOW_LATENCY)
|
val settings = AdvertiseSettings.Builder()
|
||||||
.setTxPowerLevel(AdvertiseSettings.ADVERTISE_TX_POWER_HIGH)
|
.setAdvertiseMode(AdvertiseSettings.ADVERTISE_MODE_LOW_LATENCY)
|
||||||
.setConnectable(true)
|
.setTxPowerLevel(AdvertiseSettings.ADVERTISE_TX_POWER_HIGH)
|
||||||
.build()
|
.setConnectable(true)
|
||||||
|
.build()
|
||||||
|
|
||||||
val data = AdvertiseData.Builder()
|
val data = AdvertiseData.Builder()
|
||||||
.setIncludeDeviceName(true)
|
.setIncludeDeviceName(true)
|
||||||
.addManufacturerData(MANUFACTURER_ID, MANUFACTURER_DATA.toByteArray())
|
.addManufacturerData(MANUFACTURER_ID, MANUFACTURER_DATA.toByteArray())
|
||||||
.addServiceUuid(ParcelUuid(uuid))
|
.addServiceUuid(ParcelUuid(uuid))
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
bluetoothLeAdvertiser.startAdvertising(settings, data, advertiseCallback)
|
bluetoothLeAdvertiser.startAdvertising(settings, data, advertiseCallback)
|
||||||
Log.d("AirPodsQuickSwitchService", "BLE Advertising started")
|
Log.d("CrossDevice", "BLE Advertising started")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private val advertiseCallback = object : AdvertiseCallback() {
|
private val advertiseCallback = object : AdvertiseCallback() {
|
||||||
override fun onStartSuccess(settingsInEffect: AdvertiseSettings) {
|
override fun onStartSuccess(settingsInEffect: AdvertiseSettings) {
|
||||||
Log.d("AirPodsQuickSwitchService", "BLE Advertising started successfully")
|
Log.d("CrossDevice", "BLE Advertising started successfully")
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onStartFailure(errorCode: Int) {
|
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) {
|
fun sendReceivedPacket(packet: ByteArray) {
|
||||||
Log.d("AirPodsQuickSwitchService", "Sending packet to remote device")
|
Log.d("CrossDevice", "Sending packet to remote device")
|
||||||
if (clientSocket == null) {
|
if (clientSocket == null || clientSocket!!.outputStream != null) {
|
||||||
Log.d("AirPodsQuickSwitchService", "Client socket is null")
|
Log.d("CrossDevice", "Client socket is null")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
clientSocket?.outputStream?.write(CrossDevicePackets.AIRPODS_DATA_HEADER.packet + packet)
|
clientSocket?.outputStream?.write(CrossDevicePackets.AIRPODS_DATA_HEADER.packet + packet)
|
||||||
@@ -124,14 +149,14 @@ object CrossDevice {
|
|||||||
private fun logPacket(packet: ByteArray, source: String) {
|
private fun logPacket(packet: ByteArray, source: String) {
|
||||||
val packetHex = packet.joinToString(" ") { "%02X".format(it) }
|
val packetHex = packet.joinToString(" ") { "%02X".format(it) }
|
||||||
val logEntry = "$source: $packetHex"
|
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)
|
logs.add(logEntry)
|
||||||
sharedPreferences.edit().putStringSet(packetLogKey, logs).apply()
|
sharedPreferences.edit().putStringSet(PACKET_LOG_KEY, logs).apply()
|
||||||
}
|
}
|
||||||
|
|
||||||
@SuppressLint("MissingPermission")
|
@SuppressLint("MissingPermission")
|
||||||
private fun handleClientConnection(socket: BluetoothSocket) {
|
private fun handleClientConnection(socket: BluetoothSocket) {
|
||||||
Log.d("AirPodsQuickSwitchService", "Client connected")
|
Log.d("CrossDevice", "Client connected")
|
||||||
clientSocket = socket
|
clientSocket = socket
|
||||||
val inputStream = socket.inputStream
|
val inputStream = socket.inputStream
|
||||||
val buffer = ByteArray(1024)
|
val buffer = ByteArray(1024)
|
||||||
@@ -141,7 +166,7 @@ object CrossDevice {
|
|||||||
bytes = inputStream.read(buffer)
|
bytes = inputStream.read(buffer)
|
||||||
val packet = buffer.copyOf(bytes)
|
val packet = buffer.copyOf(bytes)
|
||||||
logPacket(packet, "Relay")
|
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) {
|
if (bytes == -1) {
|
||||||
break
|
break
|
||||||
} else if (packet.contentEquals(CrossDevicePackets.REQUEST_DISCONNECT.packet)) {
|
} else if (packet.contentEquals(CrossDevicePackets.REQUEST_DISCONNECT.packet)) {
|
||||||
@@ -153,36 +178,49 @@ object CrossDevice {
|
|||||||
isAvailable = false
|
isAvailable = false
|
||||||
sharedPreferences.edit().putBoolean("CrossDeviceIsAvailable", false).apply()
|
sharedPreferences.edit().putBoolean("CrossDeviceIsAvailable", false).apply()
|
||||||
} else if (packet.contentEquals(CrossDevicePackets.REQUEST_BATTERY_BYTES.packet)) {
|
} 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)
|
sendRemotePacket(batteryBytes)
|
||||||
} else if (packet.contentEquals(CrossDevicePackets.REQUEST_ANC_BYTES.packet)) {
|
} else if (packet.contentEquals(CrossDevicePackets.REQUEST_ANC_BYTES.packet)) {
|
||||||
Log.d("AirPodsQuickSwitchService", "Received ANC request")
|
Log.d("CrossDevice", "Received ANC request")
|
||||||
sendRemotePacket(ancBytes)
|
sendRemotePacket(ancBytes)
|
||||||
} else if (packet.contentEquals(CrossDevicePackets.REQUEST_CONNECTION_STATUS.packet)) {
|
} 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)
|
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)) {
|
if (packet.sliceArray(0..3).contentEquals(CrossDevicePackets.AIRPODS_DATA_HEADER.packet)) {
|
||||||
isAvailable = true
|
isAvailable = true
|
||||||
sharedPreferences.edit().putBoolean("CrossDeviceIsAvailable", true).apply()
|
sharedPreferences.edit().putBoolean("CrossDeviceIsAvailable", true).apply()
|
||||||
val trimmedPacket = packet.drop(CrossDevicePackets.AIRPODS_DATA_HEADER.packet.size).toByteArray()
|
var 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("CrossDevice", "Received relayed packet, with ${sharedPreferences.getBoolean("CrossDeviceIsAvailable", false)} | ${ServiceManager.getService()?.earDetectionNotification?.isEarDetectionData(trimmedPacket)}")
|
||||||
Log.d("AirPodsQuickSwitchService", "Received relayed packet: ${trimmedPacket.joinToString("") { "%02x".format(it) }}")
|
Log.d("CrossDevice", "Received relayed packet: ${trimmedPacket.joinToString("") { "%02x".format(it) }}")
|
||||||
if (ServiceManager.getService()?.isConnectedLocally == true) {
|
if (ServiceManager.getService()?.isConnectedLocally == true) {
|
||||||
val packetInHex = trimmedPacket.joinToString("") { "%02x".format(it) }
|
val packetInHex = trimmedPacket.joinToString("") { "%02x".format(it) }
|
||||||
ServiceManager.getService()?.sendPacket(packetInHex)
|
ServiceManager.getService()?.sendPacket(packetInHex)
|
||||||
} else if (ServiceManager.getService()?.batteryNotification?.isBatteryData(trimmedPacket) == true) {
|
} else if (ServiceManager.getService()?.batteryNotification?.isBatteryData(trimmedPacket) == true) {
|
||||||
batteryBytes = trimmedPacket
|
batteryBytes = trimmedPacket
|
||||||
ServiceManager.getService()?.batteryNotification?.setBattery(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()?.updateBatteryWidget()
|
||||||
ServiceManager.getService()?.sendBatteryBroadcast()
|
ServiceManager.getService()?.sendBatteryBroadcast()
|
||||||
ServiceManager.getService()?.sendBatteryNotification()
|
ServiceManager.getService()?.sendBatteryNotification()
|
||||||
} else if (ServiceManager.getService()?.ancNotification?.isANCData(trimmedPacket) == true) {
|
} else if (ServiceManager.getService()?.ancNotification?.isANCData(trimmedPacket) == true) {
|
||||||
ServiceManager.getService()?.ancNotification?.setStatus(trimmedPacket)
|
ServiceManager.getService()?.ancNotification?.setStatus(trimmedPacket)
|
||||||
ServiceManager.getService()?.sendANCBroadcast()
|
ServiceManager.getService()?.sendANCBroadcast()
|
||||||
|
ServiceManager.getService()?.updateNoiseControlWidget()
|
||||||
ancBytes = trimmedPacket
|
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) {
|
fun sendRemotePacket(byteArray: ByteArray) {
|
||||||
if (clientSocket == null) {
|
if (clientSocket == null || clientSocket!!.outputStream == null) {
|
||||||
Log.d("AirPodsQuickSwitchService", "Client socket is null")
|
Log.d("CrossDevice", "Client socket is null")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
clientSocket?.outputStream?.write(byteArray)
|
clientSocket?.outputStream?.write(byteArray)
|
||||||
clientSocket?.outputStream?.flush()
|
clientSocket?.outputStream?.flush()
|
||||||
logPacket(byteArray, "Sent")
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -25,6 +25,7 @@ import android.os.Handler
|
|||||||
import android.os.Looper
|
import android.os.Looper
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import android.view.KeyEvent
|
import android.view.KeyEvent
|
||||||
|
import me.kavishdevar.aln.services.ServiceManager
|
||||||
|
|
||||||
object MediaController {
|
object MediaController {
|
||||||
private var initialVolume: Int? = null
|
private var initialVolume: Int? = null
|
||||||
@@ -34,14 +35,19 @@ object MediaController {
|
|||||||
private lateinit var sharedPreferences: SharedPreferences
|
private lateinit var sharedPreferences: SharedPreferences
|
||||||
private val handler = Handler(Looper.getMainLooper())
|
private val handler = Handler(Looper.getMainLooper())
|
||||||
|
|
||||||
|
var pausedForCrossDevice = false
|
||||||
|
|
||||||
private var relativeVolume: Boolean = false
|
private var relativeVolume: Boolean = false
|
||||||
private var conversationalAwarenessVolume: Int = 1/12
|
private var conversationalAwarenessVolume: Int = 1/12
|
||||||
private var conversationalAwarenessPauseMusic: Boolean = false
|
private var conversationalAwarenessPauseMusic: Boolean = false
|
||||||
|
|
||||||
fun initialize(audioManager: AudioManager, sharedPreferences: SharedPreferences) {
|
fun initialize(audioManager: AudioManager, sharedPreferences: SharedPreferences) {
|
||||||
|
if (this::audioManager.isInitialized) {
|
||||||
|
return
|
||||||
|
}
|
||||||
this.audioManager = audioManager
|
this.audioManager = audioManager
|
||||||
this.sharedPreferences = sharedPreferences
|
this.sharedPreferences = sharedPreferences
|
||||||
|
Log.d("MediaController", "Initializing MediaController")
|
||||||
relativeVolume = sharedPreferences.getBoolean("relative_conversational_awareness_volume", false)
|
relativeVolume = sharedPreferences.getBoolean("relative_conversational_awareness_volume", false)
|
||||||
conversationalAwarenessVolume = sharedPreferences.getInt("conversational_awareness_volume", 100/12)
|
conversationalAwarenessVolume = sharedPreferences.getInt("conversational_awareness_volume", 100/12)
|
||||||
conversationalAwarenessPauseMusic = sharedPreferences.getBoolean("conversational_awareness_pause_music", false)
|
conversationalAwarenessPauseMusic = sharedPreferences.getBoolean("conversational_awareness_pause_music", false)
|
||||||
@@ -74,6 +80,14 @@ object MediaController {
|
|||||||
userPlayedTheMedia = audioManager.isMusicActive
|
userPlayedTheMedia = audioManager.isMusicActive
|
||||||
}, 7) // i have no idea why android sends an event a hundred times after the user does something.
|
}, 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()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ import kotlinx.coroutines.launch
|
|||||||
import me.kavishdevar.aln.R
|
import me.kavishdevar.aln.R
|
||||||
|
|
||||||
@SuppressLint("InflateParams", "ClickableViewAccessibility")
|
@SuppressLint("InflateParams", "ClickableViewAccessibility")
|
||||||
class Window (context: Context) {
|
class PopupWindow(context: Context) {
|
||||||
private val mView: View
|
private val mView: View
|
||||||
|
|
||||||
@Suppress("DEPRECATION")
|
@Suppress("DEPRECATION")
|
||||||
@@ -56,13 +56,12 @@ class Window (context: Context) {
|
|||||||
gravity = Gravity.BOTTOM
|
gravity = Gravity.BOTTOM
|
||||||
dimAmount = 0.3f
|
dimAmount = 0.3f
|
||||||
flags = WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS or
|
flags = WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS or
|
||||||
WindowManager.LayoutParams.FLAG_FULLSCREEN or
|
WindowManager.LayoutParams.FLAG_FULLSCREEN or
|
||||||
WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or
|
WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or
|
||||||
WindowManager.LayoutParams.FLAG_DIM_BEHIND or
|
WindowManager.LayoutParams.FLAG_DIM_BEHIND or
|
||||||
WindowManager.LayoutParams.FLAG_WATCH_OUTSIDE_TOUCH
|
WindowManager.LayoutParams.FLAG_WATCH_OUTSIDE_TOUCH
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
private val mWindowManager: WindowManager
|
private val mWindowManager: WindowManager
|
||||||
|
|
||||||
init {
|
init {
|
||||||
@@ -72,14 +71,13 @@ class Window (context: Context) {
|
|||||||
mParams.y = 0
|
mParams.y = 0
|
||||||
|
|
||||||
mParams.gravity = Gravity.BOTTOM
|
mParams.gravity = Gravity.BOTTOM
|
||||||
mView.setOnClickListener(View.OnClickListener {
|
mView.setOnClickListener {
|
||||||
close()
|
close()
|
||||||
})
|
}
|
||||||
|
|
||||||
mView.findViewById<ImageButton>(R.id.close_button)
|
mView.findViewById<ImageButton>(R.id.close_button).setOnClickListener {
|
||||||
.setOnClickListener {
|
close()
|
||||||
close()
|
}
|
||||||
}
|
|
||||||
|
|
||||||
val ll = mView.findViewById<LinearLayout>(R.id.linear_layout)
|
val ll = mView.findViewById<LinearLayout>(R.id.linear_layout)
|
||||||
ll.setOnClickListener {
|
ll.setOnClickListener {
|
||||||
@@ -88,11 +86,11 @@ class Window (context: Context) {
|
|||||||
|
|
||||||
@Suppress("DEPRECATION")
|
@Suppress("DEPRECATION")
|
||||||
mView.systemUiVisibility = View.SYSTEM_UI_FLAG_LAYOUT_STABLE or
|
mView.systemUiVisibility = View.SYSTEM_UI_FLAG_LAYOUT_STABLE or
|
||||||
View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION or
|
View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION or
|
||||||
View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN or
|
View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN or
|
||||||
View.SYSTEM_UI_FLAG_HIDE_NAVIGATION or
|
View.SYSTEM_UI_FLAG_HIDE_NAVIGATION or
|
||||||
View.SYSTEM_UI_FLAG_FULLSCREEN or
|
View.SYSTEM_UI_FLAG_FULLSCREEN or
|
||||||
View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY
|
View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY
|
||||||
|
|
||||||
mView.setOnTouchListener { _, event ->
|
mView.setOnTouchListener { _, event ->
|
||||||
if (event.action == MotionEvent.ACTION_DOWN) {
|
if (event.action == MotionEvent.ACTION_DOWN) {
|
||||||
@@ -116,7 +114,6 @@ class Window (context: Context) {
|
|||||||
try {
|
try {
|
||||||
if (mView.windowToken == null) {
|
if (mView.windowToken == null) {
|
||||||
if (mView.parent == null) {
|
if (mView.parent == null) {
|
||||||
// Add the view initially off-screen
|
|
||||||
mWindowManager.addView(mView, mParams)
|
mWindowManager.addView(mView, mParams)
|
||||||
mView.findViewById<TextView>(R.id.name).text = name
|
mView.findViewById<TextView>(R.id.name).text = name
|
||||||
val vid = mView.findViewById<VideoView>(R.id.video)
|
val vid = mView.findViewById<VideoView>(R.id.video)
|
||||||
@@ -143,14 +140,13 @@ class Window (context: Context) {
|
|||||||
"\uDBC3\uDE6C ${it.level}%"
|
"\uDBC3\uDE6C ${it.level}%"
|
||||||
} ?: ""
|
} ?: ""
|
||||||
|
|
||||||
// Slide-up animation
|
|
||||||
val displayMetrics = mView.context.resources.displayMetrics
|
val displayMetrics = mView.context.resources.displayMetrics
|
||||||
val screenHeight = displayMetrics.heightPixels
|
val screenHeight = displayMetrics.heightPixels
|
||||||
|
|
||||||
mView.translationY = screenHeight.toFloat() // Start below the screen
|
mView.translationY = screenHeight.toFloat()
|
||||||
ObjectAnimator.ofFloat(mView, "translationY", 0f).apply {
|
ObjectAnimator.ofFloat(mView, "translationY", 0f).apply {
|
||||||
duration = 500 // Animation duration in milliseconds
|
duration = 500
|
||||||
interpolator = DecelerateInterpolator() // Smooth deceleration
|
interpolator = DecelerateInterpolator()
|
||||||
start()
|
start()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -168,8 +164,8 @@ class Window (context: Context) {
|
|||||||
fun close() {
|
fun close() {
|
||||||
try {
|
try {
|
||||||
ObjectAnimator.ofFloat(mView, "translationY", mView.height.toFloat()).apply {
|
ObjectAnimator.ofFloat(mView, "translationY", mView.height.toFloat()).apply {
|
||||||
duration = 500 // Animation duration in milliseconds
|
duration = 500
|
||||||
interpolator = AccelerateInterpolator() // Smooth acceleration
|
interpolator = AccelerateInterpolator()
|
||||||
addListener(object : AnimatorListenerAdapter() {
|
addListener(object : AnimatorListenerAdapter() {
|
||||||
override fun onAnimationEnd(animation: Animator) {
|
override fun onAnimationEnd(animation: Animator) {
|
||||||
try {
|
try {
|
||||||
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>
|
<resources>
|
||||||
<string name="app_name" translatable="false">ALN</string>
|
<string name="app_name" translatable="false">ALN</string>
|
||||||
<string name="title_activity_custom_device" translatable="false">GATT Testing</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="app_widget_description">See your AirPods battery status right from your home screen!</string>
|
||||||
<string name="accessibility">Accessibility</string>
|
<string name="accessibility">Accessibility</string>
|
||||||
<string name="tone_volume">Tone Volume</string>
|
<string name="tone_volume">Tone Volume</string>
|
||||||
<string name="audio">Audio</string>
|
<string name="audio">Audio</string>
|
||||||
<string name="adaptive_audio">Adaptive 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="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="buds">Buds</string>
|
||||||
<string name="case_alt">Case</string>
|
<string name="case_alt">Case</string>
|
||||||
<string name="test">Test</string>
|
<string name="test">Test</string>
|
||||||
<string name="name">Name</string>
|
<string name="name">Name</string>
|
||||||
<string name="noise_control">Noise Control</string>
|
<string name="noise_control">Noise Control</string>
|
||||||
<string name="off">Off</string>
|
<string name="off">Off</string>
|
||||||
<string name="transparency">Transparency</string>
|
<string name="transparency">Transparency</string>
|
||||||
<string name="adaptive">Adaptive</string>
|
<string name="adaptive">Adaptive</string>
|
||||||
<string name="noise_cancellation">Noise Cancellation</string>
|
<string name="noise_cancellation">Noise Cancellation</string>
|
||||||
<string name="press_and_hold_airpods">Press and Hold AirPods</string>
|
<string name="press_and_hold_airpods">Press and Hold AirPods</string>
|
||||||
<string name="left">Left</string>
|
<string name="left">Left</string>
|
||||||
<string name="right">Right</string>
|
<string name="right">Right</string>
|
||||||
<string name="adjusts_volume">Adjusts the volume of media in response to your environment</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">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="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">Personalized Volume</string>
|
||||||
<string name="personalized_volume_description">Adjusts the volume of media in response to your environment.</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="less_noise">Less Noise</string>
|
||||||
<string name="more_noise">More 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">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="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">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="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">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="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="back">Back</string>
|
||||||
<string name="app_settings">App Settings</string>
|
<string name="app_settings">App Settings</string>
|
||||||
<string name="conversational_awareness_customization">Conversational Awareness</string>
|
<string name="conversational_awareness_customization">Conversational Awareness</string>
|
||||||
<string name="relative_conversational_awareness_volume">Relative volume</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="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">Pause Music</string>
|
||||||
<string name="conversational_awareness_pause_music_description">When you start speaking, music will be paused.</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="appwidget_text">EXAMPLE</string>
|
||||||
<string name="add_widget">Add widget</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="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>
|
</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
|
qt_add_executable(applinux
|
||||||
main.cpp
|
main.cpp
|
||||||
AirPodsTrayApp.cpp
|
|
||||||
BluetoothHandler.cpp
|
|
||||||
PacketDefinitions.cpp
|
|
||||||
)
|
)
|
||||||
|
|
||||||
qt_add_qml_module(applinux
|
qt_add_qml_module(applinux
|
||||||
|
|||||||
@@ -21,12 +21,14 @@ ApplicationWindow {
|
|||||||
text: "Battery Status: "
|
text: "Battery Status: "
|
||||||
id: batteryStatus
|
id: batteryStatus
|
||||||
objectName: "batteryStatus"
|
objectName: "batteryStatus"
|
||||||
|
color: "#ffffff"
|
||||||
}
|
}
|
||||||
|
|
||||||
Text {
|
Text {
|
||||||
text: "Ear Detection Status: "
|
text: "Ear Detection Status: "
|
||||||
id: earDetectionStatus
|
id: earDetectionStatus
|
||||||
objectName: "earDetectionStatus"
|
objectName: "earDetectionStatus"
|
||||||
|
color: "#ffffff"
|
||||||
}
|
}
|
||||||
|
|
||||||
ComboBox {
|
ComboBox {
|
||||||
|
|||||||
214
linux/main.cpp
214
linux/main.cpp
@@ -39,7 +39,6 @@ Q_LOGGING_CATEGORY(airpodsApp, "airpodsApp")
|
|||||||
|
|
||||||
#define PHONE_MAC_ADDRESS "22:22:F5:BB:1C:A0"
|
#define PHONE_MAC_ADDRESS "22:22:F5:BB:1C:A0"
|
||||||
|
|
||||||
// Define Manufacturer Specific Data Identifier
|
|
||||||
#define MANUFACTURER_ID 0x1234
|
#define MANUFACTURER_ID 0x1234
|
||||||
#define MANUFACTURER_DATA "ALN_AirPods"
|
#define MANUFACTURER_DATA "ALN_AirPods"
|
||||||
|
|
||||||
@@ -92,7 +91,7 @@ public:
|
|||||||
connect(trayIcon, &QSystemTrayIcon::activated, this, &AirPodsTrayApp::onTrayIconActivated);
|
connect(trayIcon, &QSystemTrayIcon::activated, this, &AirPodsTrayApp::onTrayIconActivated);
|
||||||
|
|
||||||
discoveryAgent = new QBluetoothDeviceDiscoveryAgent();
|
discoveryAgent = new QBluetoothDeviceDiscoveryAgent();
|
||||||
discoveryAgent->setLowEnergyDiscoveryTimeout(5000);
|
discoveryAgent->setLowEnergyDiscoveryTimeout(15000);
|
||||||
|
|
||||||
connect(discoveryAgent, &QBluetoothDeviceDiscoveryAgent::deviceDiscovered, this, &AirPodsTrayApp::onDeviceDiscovered);
|
connect(discoveryAgent, &QBluetoothDeviceDiscoveryAgent::deviceDiscovered, this, &AirPodsTrayApp::onDeviceDiscovered);
|
||||||
connect(discoveryAgent, &QBluetoothDeviceDiscoveryAgent::finished, this, &AirPodsTrayApp::onDiscoveryFinished);
|
connect(discoveryAgent, &QBluetoothDeviceDiscoveryAgent::finished, this, &AirPodsTrayApp::onDiscoveryFinished);
|
||||||
@@ -114,12 +113,10 @@ public:
|
|||||||
initializeMprisInterface();
|
initializeMprisInterface();
|
||||||
connect(phoneSocket, &QBluetoothSocket::readyRead, this, &AirPodsTrayApp::onPhoneDataReceived);
|
connect(phoneSocket, &QBluetoothSocket::readyRead, this, &AirPodsTrayApp::onPhoneDataReceived);
|
||||||
|
|
||||||
// After starting discovery, check if service record exists
|
|
||||||
QDBusInterface iface("org.bluez", "/org/bluez", "org.bluez.Adapter1");
|
QDBusInterface iface("org.bluez", "/org/bluez", "org.bluez.Adapter1");
|
||||||
QDBusReply<QVariant> reply = iface.call("GetServiceRecords", QString::fromUtf8("74ec2172-0bad-4d01-8f77-997b2be0722a"));
|
QDBusReply<QVariant> reply = iface.call("GetServiceRecords", QString::fromUtf8("74ec2172-0bad-4d01-8f77-997b2be0722a"));
|
||||||
if (reply.isValid()) {
|
if (reply.isValid()) {
|
||||||
LOG_INFO("Service record found, proceeding with connection");
|
LOG_INFO("Service record found, proceeding with connection");
|
||||||
// Proceed with existing connection logic
|
|
||||||
} else {
|
} else {
|
||||||
LOG_WARN("Service record not found, waiting for BLE broadcast");
|
LOG_WARN("Service record not found, waiting for BLE broadcast");
|
||||||
}
|
}
|
||||||
@@ -236,7 +233,7 @@ public slots:
|
|||||||
bool secondaryInEar = parts[1].contains("In Ear");
|
bool secondaryInEar = parts[1].contains("In Ear");
|
||||||
|
|
||||||
if (primaryInEar && secondaryInEar) {
|
if (primaryInEar && secondaryInEar) {
|
||||||
if (wasPausedByApp) {
|
if (wasPausedByApp && isActiveOutputDeviceAirPods()) {
|
||||||
QProcess::execute("playerctl", QStringList() << "play");
|
QProcess::execute("playerctl", QStringList() << "play");
|
||||||
LOG_INFO("Resumed playback via Playerctl");
|
LOG_INFO("Resumed playback via Playerctl");
|
||||||
wasPausedByApp = false;
|
wasPausedByApp = false;
|
||||||
@@ -245,15 +242,17 @@ public slots:
|
|||||||
activateA2dpProfile();
|
activateA2dpProfile();
|
||||||
} else {
|
} else {
|
||||||
LOG_INFO("At least one AirPod is out of ear");
|
LOG_INFO("At least one AirPod is out of ear");
|
||||||
QProcess process;
|
if (isActiveOutputDeviceAirPods()) {
|
||||||
process.start("playerctl", QStringList() << "status");
|
QProcess process;
|
||||||
process.waitForFinished();
|
process.start("playerctl", QStringList() << "status");
|
||||||
QString playbackStatus = process.readAllStandardOutput().trimmed();
|
process.waitForFinished();
|
||||||
LOG_DEBUG("Playback status: " << playbackStatus);
|
QString playbackStatus = process.readAllStandardOutput().trimmed();
|
||||||
if (playbackStatus == "Playing") {
|
LOG_DEBUG("Playback status: " << playbackStatus);
|
||||||
QProcess::execute("playerctl", QStringList() << "pause");
|
if (playbackStatus == "Playing") {
|
||||||
LOG_INFO("Paused playback via Playerctl");
|
QProcess::execute("playerctl", QStringList() << "pause");
|
||||||
wasPausedByApp = true;
|
LOG_INFO("Paused playback via Playerctl");
|
||||||
|
wasPausedByApp = true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (!primaryInEar && !secondaryInEar) {
|
if (!primaryInEar && !secondaryInEar) {
|
||||||
removeAudioOutputDevice();
|
removeAudioOutputDevice();
|
||||||
@@ -308,7 +307,6 @@ public slots:
|
|||||||
QByteArray manufacturerData = device.manufacturerData(MANUFACTURER_ID);
|
QByteArray manufacturerData = device.manufacturerData(MANUFACTURER_ID);
|
||||||
if (manufacturerData.startsWith(MANUFACTURER_DATA)) {
|
if (manufacturerData.startsWith(MANUFACTURER_DATA)) {
|
||||||
LOG_INFO("Detected AirPods via BLE manufacturer data");
|
LOG_INFO("Detected AirPods via BLE manufacturer data");
|
||||||
// Initiate RFComm connection
|
|
||||||
connectToDevice(device.address().toString());
|
connectToDevice(device.address().toString());
|
||||||
}
|
}
|
||||||
LOG_INFO("Device discovered: " << device.name() << " (" << device.address().toString() << ")");
|
LOG_INFO("Device discovered: " << device.name() << " (" << device.address().toString() << ")");
|
||||||
@@ -320,7 +318,6 @@ public slots:
|
|||||||
|
|
||||||
void onDiscoveryFinished() {
|
void onDiscoveryFinished() {
|
||||||
LOG_INFO("Device discovery finished");
|
LOG_INFO("Device discovery finished");
|
||||||
// Restart discovery to continuously listen for broadcasts
|
|
||||||
discoveryAgent->start();
|
discoveryAgent->start();
|
||||||
const QList<QBluetoothDeviceInfo> discoveredDevices = discoveryAgent->discoveredDevices();
|
const QList<QBluetoothDeviceInfo> discoveredDevices = discoveryAgent->discoveredDevices();
|
||||||
for (const QBluetoothDeviceInfo &device : discoveredDevices) {
|
for (const QBluetoothDeviceInfo &device : discoveredDevices) {
|
||||||
@@ -360,76 +357,67 @@ public slots:
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
LOG_INFO("Checking connection status with phone before connecting to device: " << device.name());
|
LOG_INFO("Connecting to device: " << device.name());
|
||||||
QByteArray connectionStatusRequest = QByteArray::fromHex("00020003");
|
QBluetoothSocket *localSocket = new QBluetoothSocket(QBluetoothServiceInfo::RfcommProtocol);
|
||||||
if (phoneSocket && phoneSocket->isOpen()) {
|
connect(localSocket, &QBluetoothSocket::connected, this, [this, localSocket]() {
|
||||||
phoneSocket->write(connectionStatusRequest);
|
LOG_INFO("Connected to device, sending initial packets");
|
||||||
LOG_DEBUG("Connection status request packet written: " << connectionStatusRequest.toHex());
|
discoveryAgent->stop();
|
||||||
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);
|
QByteArray handshakePacket = QByteArray::fromHex("00000400010002000000000000000000");
|
||||||
connect(localSocket, &QBluetoothSocket::connected, this, [this, localSocket]() {
|
QByteArray setSpecificFeaturesPacket = QByteArray::fromHex("040004004d00ff00000000000000");
|
||||||
LOG_INFO("Connected to device, sending initial packets");
|
QByteArray requestNotificationsPacket = QByteArray::fromHex("040004000f00ffffffffff");
|
||||||
discoveryAgent->stop();
|
|
||||||
|
|
||||||
QByteArray handshakePacket = QByteArray::fromHex("00000400010002000000000000000000");
|
qint64 bytesWritten = localSocket->write(handshakePacket);
|
||||||
QByteArray setSpecificFeaturesPacket = QByteArray::fromHex("040004004d00ff00000000000000");
|
LOG_DEBUG("Handshake packet written: " << handshakePacket.toHex() << ", bytes written: " << bytesWritten);
|
||||||
QByteArray requestNotificationsPacket = QByteArray::fromHex("040004000f00ffffffffff");
|
localSocket->write(setSpecificFeaturesPacket);
|
||||||
|
LOG_DEBUG("Set specific features packet written: " << setSpecificFeaturesPacket.toHex());
|
||||||
qint64 bytesWritten = localSocket->write(handshakePacket);
|
localSocket->write(requestNotificationsPacket);
|
||||||
LOG_DEBUG("Handshake packet written: " << handshakePacket.toHex() << ", bytes written: " << bytesWritten);
|
LOG_DEBUG("Request notifications packet written: " << requestNotificationsPacket.toHex());
|
||||||
|
connect(localSocket, &QBluetoothSocket::bytesWritten, this, [this, localSocket, setSpecificFeaturesPacket, requestNotificationsPacket](qint64 bytes) {
|
||||||
QByteArray airpodsConnectedPacket = QByteArray::fromHex("000400010001");
|
LOG_INFO("Bytes written: " << bytes);
|
||||||
phoneSocket->write(airpodsConnectedPacket);
|
if (bytes > 0) {
|
||||||
LOG_DEBUG("AIRPODS_CONNECTED packet written: " << airpodsConnectedPacket.toHex());
|
static int step = 0;
|
||||||
|
switch (step) {
|
||||||
connect(localSocket, &QBluetoothSocket::bytesWritten, this, [this, localSocket, setSpecificFeaturesPacket, requestNotificationsPacket](qint64 bytes) {
|
case 0:
|
||||||
LOG_INFO("Bytes written: " << bytes);
|
localSocket->write(setSpecificFeaturesPacket);
|
||||||
if (bytes > 0) {
|
LOG_DEBUG("Set specific features packet written: " << setSpecificFeaturesPacket.toHex());
|
||||||
static int step = 0;
|
step++;
|
||||||
switch (step) {
|
break;
|
||||||
case 0:
|
case 1:
|
||||||
localSocket->write(setSpecificFeaturesPacket);
|
localSocket->write(requestNotificationsPacket);
|
||||||
LOG_DEBUG("Set specific features packet written: " << setSpecificFeaturesPacket.toHex());
|
LOG_DEBUG("Request notifications packet written: " << requestNotificationsPacket.toHex());
|
||||||
step++;
|
step++;
|
||||||
break;
|
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(":", "_");
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
} 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) {
|
void parseData(const QByteArray &data) {
|
||||||
@@ -474,7 +462,7 @@ public slots:
|
|||||||
LOG_INFO("Conversational awareness: " << (lowered ? "enabled" : "disabled"));
|
LOG_INFO("Conversational awareness: " << (lowered ? "enabled" : "disabled"));
|
||||||
|
|
||||||
if (lowered) {
|
if (lowered) {
|
||||||
if (initialVolume == -1) {
|
if (initialVolume == -1 && isActiveOutputDeviceAirPods()) {
|
||||||
QProcess process;
|
QProcess process;
|
||||||
process.start("pactl", QStringList() << "get-sink-volume" << "@DEFAULT_SINK@");
|
process.start("pactl", QStringList() << "get-sink-volume" << "@DEFAULT_SINK@");
|
||||||
process.waitForFinished();
|
process.waitForFinished();
|
||||||
@@ -492,7 +480,7 @@ public slots:
|
|||||||
QProcess::execute("pactl", QStringList() << "set-sink-volume" << "@DEFAULT_SINK@" << QString::number(initialVolume * 0.20) + "%");
|
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 << "%");
|
LOG_INFO("Volume lowered to 0.20 of initial which is " << initialVolume * 0.20 << "%");
|
||||||
} else {
|
} else {
|
||||||
if (initialVolume != -1) {
|
if (initialVolume != -1 && isActiveOutputDeviceAirPods()) {
|
||||||
QProcess::execute("pactl", QStringList() << "set-sink-volume" << "@DEFAULT_SINK@" << QString::number(initialVolume) + "%");
|
QProcess::execute("pactl", QStringList() << "set-sink-volume" << "@DEFAULT_SINK@" << QString::number(initialVolume) + "%");
|
||||||
LOG_INFO("Volume restored to " << initialVolume << "%");
|
LOG_INFO("Volume restored to " << initialVolume << "%");
|
||||||
initialVolume = -1;
|
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() {
|
void initializeMprisInterface() {
|
||||||
QStringList services = QDBusConnection::sessionBus().interface()->registeredServiceNames();
|
QStringList services = QDBusConnection::sessionBus().interface()->registeredServiceNames();
|
||||||
QString mprisService;
|
QString mprisService;
|
||||||
@@ -560,7 +557,7 @@ public slots:
|
|||||||
|
|
||||||
void handlePhonePacket(const QByteArray &packet) {
|
void handlePhonePacket(const QByteArray &packet) {
|
||||||
if (packet.startsWith(QByteArray::fromHex("00040001"))) {
|
if (packet.startsWith(QByteArray::fromHex("00040001"))) {
|
||||||
QByteArray airpodsPacket = packet.mid(4); // Remove the header
|
QByteArray airpodsPacket = packet.mid(4);
|
||||||
if (socket && socket->isOpen()) {
|
if (socket && socket->isOpen()) {
|
||||||
socket->write(airpodsPacket);
|
socket->write(airpodsPacket);
|
||||||
LOG_DEBUG("Relayed packet to AirPods: " << airpodsPacket.toHex());
|
LOG_DEBUG("Relayed packet to AirPods: " << airpodsPacket.toHex());
|
||||||
@@ -569,15 +566,24 @@ public slots:
|
|||||||
}
|
}
|
||||||
} else if (packet.startsWith(QByteArray::fromHex("00010001"))) {
|
} else if (packet.startsWith(QByteArray::fromHex("00010001"))) {
|
||||||
LOG_INFO("AirPods connected");
|
LOG_INFO("AirPods connected");
|
||||||
// Handle AirPods connected
|
|
||||||
} else if (packet.startsWith(QByteArray::fromHex("00010000"))) {
|
} else if (packet.startsWith(QByteArray::fromHex("00010000"))) {
|
||||||
LOG_INFO("AirPods disconnected");
|
LOG_INFO("AirPods disconnected");
|
||||||
// Handle AirPods disconnected
|
|
||||||
} else if (packet.startsWith(QByteArray::fromHex("00020003"))) {
|
} else if (packet.startsWith(QByteArray::fromHex("00020003"))) {
|
||||||
LOG_INFO("Connection status request received");
|
LOG_INFO("Connection status request received");
|
||||||
QByteArray response = (socket && socket->isOpen()) ? QByteArray::fromHex("00010001") : QByteArray::fromHex("00010000");
|
QByteArray response = (socket && socket->isOpen()) ? QByteArray::fromHex("00010001") : QByteArray::fromHex("00010000");
|
||||||
phoneSocket->write(response);
|
phoneSocket->write(response);
|
||||||
LOG_DEBUG("Sent connection status response: " << response.toHex());
|
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 {
|
} else {
|
||||||
if (socket && socket->isOpen()) {
|
if (socket && socket->isOpen()) {
|
||||||
socket->write(packet);
|
socket->write(packet);
|
||||||
@@ -591,7 +597,37 @@ public slots:
|
|||||||
void onPhoneDataReceived() {
|
void onPhoneDataReceived() {
|
||||||
QByteArray data = phoneSocket->readAll();
|
QByteArray data = phoneSocket->readAll();
|
||||||
LOG_DEBUG("Data received from phone: " << data.toHex());
|
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:
|
signals:
|
||||||
@@ -673,6 +709,8 @@ int main(int argc, char *argv[]) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
trayApp.followMediaChanges();
|
||||||
|
|
||||||
return app.exec();
|
return app.exec();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user