Skip to content

Commit 321a3bd

Browse files
committed
finally done with most of the crossdevice stuff!
1 parent 5cee33a commit 321a3bd

File tree

14 files changed

+574
-144
lines changed

14 files changed

+574
-144
lines changed

README.md

Lines changed: 5 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -20,16 +20,13 @@ Check the [pinned issue](https://github.com/kavishdevar/aln/issues/20) for a lis
2020
2121
### Features
2222

23-
- **Battery Status**: Get battery status on your Android device when you connect your AirPods to your Linux device.
24-
- **Control AirPods**: Control your AirPods from your Android device when connected to your Linux device, like changing the noise control mode, toggling conversational awareness, and more.
25-
- **Automatic Device Switching**: Automatically switch between your Linux and Android device when you connect your AirPods to one of them.
23+
- **Battery Status**: Get battery status on any device when you connect your AirPods to one of them.
24+
- **Control AirPods**: Control your AirPods from either of your device when you connect to one, like changing the noise control mode, toggling conversational awareness, and more.
25+
- **Automatic Device Switching**: Automatically switch between your Linux and Android device, like when you receive a call, start playing music on Android while you're connected to Linux, and more!
2626

27-
> [!NOTE]
28-
> All this currently works only one way, Linux to Android, i.e. if you connect your AirPods to your Linux device, the features mentioned. The Android app can automaticaly connect to your AirPods when *receiving calls* or *starting media playback*.
27+
Check out the demo below!
2928

30-
| | |
31-
|-------------------|-------------------|
32-
| Connected to Linux, all features of the app available (setting Noise Control mode, battery status, etc.). ![Connected Remotely](/android/imgs/cd-connected-remotely-island.png) | Call received or media started playing; phone took over audio and disconnected AirPods from linux. ![Moved to Phone](/android/imgs/cd-moved-to-phone-island.png) |
29+
https://raw.githubusercontent.com/kavishdevar/aln/main/android/imgs/cd-demo-2.mp4
3330

3431
## Linux — Deprecated, rewrite WIP!
3532

android/app/src/main/java/me/kavishdevar/aln/MainActivity.kt

Lines changed: 1 addition & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -23,12 +23,9 @@ import android.content.BroadcastReceiver
2323
import android.content.ComponentName
2424
import android.content.Context
2525
import android.content.Context.MODE_PRIVATE
26-
import android.content.Context.RECEIVER_EXPORTED
2726
import android.content.Intent
28-
import android.content.IntentFilter
2927
import android.content.ServiceConnection
3028
import android.content.SharedPreferences
31-
import android.os.Build
3229
import android.os.Bundle
3330
import android.os.IBinder
3431
import android.util.Log
@@ -91,6 +88,7 @@ class MainActivity : ComponentActivity() {
9188
}
9289
}
9390
}
91+
9492
override fun onDestroy() {
9593
try {
9694
unbindService(serviceConnection)
@@ -144,29 +142,6 @@ fun Main() {
144142
val context = LocalContext.current
145143
val navController = rememberNavController()
146144

147-
connectionStatusReceiver = object : BroadcastReceiver() {
148-
override fun onReceive(context: Context, intent: Intent) {
149-
if (intent.action == AirPodsNotifications.AIRPODS_CONNECTED) {
150-
Log.d("MainActivity", "AirPods Connected intent received")
151-
isConnected.value = true
152-
}
153-
else if (intent.action == AirPodsNotifications.AIRPODS_DISCONNECTED) {
154-
Log.d("MainActivity", "AirPods Disconnected intent received")
155-
isRemotelyConnected.value = CrossDevice.isAvailable
156-
isConnected.value = false
157-
}
158-
else if (intent.action == AirPodsNotifications.DISCONNECT_RECEIVERS) {
159-
Log.d("MainActivity", "Disconnect Receivers intent received")
160-
try {
161-
context.unregisterReceiver(this)
162-
}
163-
catch (e: Exception) {
164-
Log.e("MainActivity", "Error while unregistering receiver: $e")
165-
}
166-
}
167-
}
168-
}
169-
170145
val sharedPreferences = context.getSharedPreferences("settings", MODE_PRIVATE)
171146
val isAvailableChangeListener = SharedPreferences.OnSharedPreferenceChangeListener { _, key ->
172147
if (key == "CrossDeviceIsAvailable") {
@@ -178,20 +153,6 @@ fun Main() {
178153
Log.d("MainActivity", "CrossDeviceIsAvailable: ${sharedPreferences.getBoolean("CrossDeviceIsAvailable", false)} | isAvailable: ${CrossDevice.isAvailable}")
179154
isRemotelyConnected.value = sharedPreferences.getBoolean("CrossDeviceIsAvailable", false) || CrossDevice.isAvailable
180155
Log.d("MainActivity", "isRemotelyConnected: ${isRemotelyConnected.value}")
181-
val filter = IntentFilter().apply {
182-
addAction(AirPodsNotifications.AIRPODS_CONNECTED)
183-
addAction(AirPodsNotifications.AIRPODS_DISCONNECTED)
184-
addAction(AirPodsNotifications.AIRPODS_CONNECTION_DETECTED)
185-
addAction(AirPodsNotifications.DISCONNECT_RECEIVERS)
186-
}
187-
Log.d("MainActivity", "Registering Receiver")
188-
189-
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
190-
context.registerReceiver(connectionStatusReceiver, filter, RECEIVER_EXPORTED)
191-
} else {
192-
context.registerReceiver(connectionStatusReceiver, filter)
193-
}
194-
Log.d("MainActivity", "Registered Receiver")
195156
Box (
196157
modifier = Modifier
197158
.padding(0.dp)

android/app/src/main/java/me/kavishdevar/aln/composables/AccessibilitySettings.kt

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,17 @@
11
/*
22
* AirPods like Normal (ALN) - Bringing Apple-only features to Linux and Android for seamless AirPods functionality!
3-
*
3+
*
44
* Copyright (C) 2024 Kavish Devar
5-
*
5+
*
66
* This program is free software: you can redistribute it and/or modify
77
* it under the terms of the GNU Affero General Public License as published
88
* by the Free Software Foundation, either version 3 of the License.
9-
*
9+
*
1010
* This program is distributed in the hope that it will be useful,
1111
* but WITHOUT ANY WARRANTY; without even the implied warranty of
1212
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
1313
* GNU Affero General Public License for more details.
14-
*
14+
*
1515
* You should have received a copy of the GNU Affero General Public License
1616
* along with this program. If not, see <https://www.gnu.org/licenses/>.
1717
*/

android/app/src/main/java/me/kavishdevar/aln/composables/ConversationalAwarenessSwitch.kt

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,17 @@
11
/*
22
* AirPods like Normal (ALN) - Bringing Apple-only features to Linux and Android for seamless AirPods functionality!
3-
*
3+
*
44
* Copyright (C) 2024 Kavish Devar
5-
*
5+
*
66
* This program is free software: you can redistribute it and/or modify
77
* it under the terms of the GNU Affero General Public License as published
88
* by the Free Software Foundation, either version 3 of the License.
9-
*
9+
*
1010
* This program is distributed in the hope that it will be useful,
1111
* but WITHOUT ANY WARRANTY; without even the implied warranty of
1212
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
1313
* GNU Affero General Public License for more details.
14-
*
14+
*
1515
* You should have received a copy of the GNU Affero General Public License
1616
* along with this program. If not, see <https://www.gnu.org/licenses/>.
1717
*/
@@ -122,4 +122,4 @@ fun ConversationalAwarenessSwitch(service: AirPodsService, sharedPreferences: Sh
122122
@Composable
123123
fun ConversationalAwarenessSwitchPreview() {
124124
ConversationalAwarenessSwitch(AirPodsService(), LocalContext.current.getSharedPreferences("preview", 0))
125-
}
125+
}
Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
1+
/*
2+
* AirPods like Normal (ALN) - Bringing Apple-only features to Linux and Android for seamless AirPods functionality!
3+
*
4+
* Copyright (C) 2024 Kavish Devar
5+
*
6+
* This program is free software: you can redistribute it and/or modify
7+
* it under the terms of the GNU Affero General Public License as published
8+
* by the Free Software Foundation, either version 3 of the License.
9+
*
10+
* This program is distributed in the hope that it will be useful,
11+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
12+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13+
* GNU Affero General Public License for more details.
14+
*
15+
* You should have received a copy of the GNU Affero General Public License
16+
* along with this program. If not, see <https://www.gnu.org/licenses/>.
17+
*/
18+
19+
package me.kavishdevar.aln.composables
20+
21+
import androidx.compose.animation.core.Spring
22+
import androidx.compose.animation.core.animateDpAsState
23+
import androidx.compose.animation.core.animateFloatAsState
24+
import androidx.compose.animation.core.spring
25+
import androidx.compose.foundation.background
26+
import androidx.compose.foundation.clickable
27+
import androidx.compose.foundation.interaction.MutableInteractionSource
28+
import androidx.compose.foundation.isSystemInDarkTheme
29+
import androidx.compose.foundation.layout.Column
30+
import androidx.compose.foundation.layout.Row
31+
import androidx.compose.foundation.layout.Spacer
32+
import androidx.compose.foundation.layout.fillMaxWidth
33+
import androidx.compose.foundation.layout.height
34+
import androidx.compose.foundation.layout.padding
35+
import androidx.compose.foundation.layout.widthIn
36+
import androidx.compose.foundation.shape.RoundedCornerShape
37+
import androidx.compose.material3.Text
38+
import androidx.compose.runtime.Composable
39+
import androidx.compose.runtime.getValue
40+
import androidx.compose.runtime.mutableStateOf
41+
import androidx.compose.runtime.remember
42+
import androidx.compose.runtime.setValue
43+
import androidx.compose.ui.Alignment
44+
import androidx.compose.ui.Modifier
45+
import androidx.compose.ui.draw.scale
46+
import androidx.compose.ui.geometry.Offset
47+
import androidx.compose.ui.graphics.Color
48+
import androidx.compose.ui.layout.onGloballyPositioned
49+
import androidx.compose.ui.platform.LocalDensity
50+
import androidx.compose.ui.text.font.Font
51+
import androidx.compose.ui.text.font.FontFamily
52+
import androidx.compose.ui.tooling.preview.Preview
53+
import androidx.compose.ui.unit.IntOffset
54+
import androidx.compose.ui.unit.dp
55+
import androidx.compose.ui.unit.sp
56+
import androidx.compose.ui.window.Popup
57+
import androidx.compose.ui.window.PopupProperties
58+
import me.kavishdevar.aln.R
59+
60+
class DropdownItem(val name: String, val onSelect: () -> Unit) {
61+
fun select() {
62+
onSelect()
63+
}
64+
}
65+
66+
@Composable
67+
fun CustomDropdown(name: String, description: String = "", items: List<DropdownItem>) {
68+
val isDarkTheme = isSystemInDarkTheme()
69+
val textColor = if (isDarkTheme) Color.White else Color.Black
70+
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
71+
var expanded by remember { mutableStateOf(false) }
72+
var offset by remember { mutableStateOf(IntOffset.Zero) }
73+
var popupHeight by remember { mutableStateOf(0.dp) }
74+
75+
val animatedHeight by animateDpAsState(
76+
targetValue = if (expanded) popupHeight else 0.dp,
77+
animationSpec = spring(dampingRatio = Spring.DampingRatioMediumBouncy, stiffness = Spring.StiffnessLow)
78+
)
79+
val animatedScale by animateFloatAsState(
80+
targetValue = if (expanded) 1f else 0f,
81+
animationSpec = spring(dampingRatio = Spring.DampingRatioMediumBouncy, stiffness = Spring.StiffnessLow)
82+
)
83+
84+
Row(
85+
modifier = Modifier
86+
.fillMaxWidth()
87+
.background(
88+
shape = RoundedCornerShape(14.dp),
89+
color = Color.Transparent
90+
)
91+
.padding(horizontal = 12.dp, vertical = 12.dp)
92+
.clickable(
93+
indication = null,
94+
interactionSource = remember { MutableInteractionSource() }
95+
) {
96+
expanded = true
97+
}
98+
.onGloballyPositioned { coordinates ->
99+
val windowPosition = coordinates.localToWindow(Offset.Zero)
100+
offset = IntOffset(windowPosition.x.toInt(), windowPosition.y.toInt() + coordinates.size.height)
101+
},
102+
verticalAlignment = Alignment.CenterVertically
103+
) {
104+
Column(
105+
modifier = Modifier
106+
.weight(1f)
107+
.padding(end = 4.dp)
108+
) {
109+
Text(
110+
text = name,
111+
fontSize = 16.sp,
112+
color = textColor,
113+
maxLines = 1
114+
)
115+
if (description.isNotEmpty()) {
116+
Spacer(modifier = Modifier.height(4.dp))
117+
Text(
118+
text = description,
119+
fontSize = 12.sp,
120+
color = textColor.copy(0.6f),
121+
lineHeight = 14.sp,
122+
maxLines = 1
123+
)
124+
}
125+
}
126+
Text(
127+
text = "\uDBC0\uDD8F",
128+
fontSize = 16.sp,
129+
fontFamily = FontFamily(Font(R.font.sf_pro)),
130+
color = textColor
131+
)
132+
}
133+
134+
if (expanded) {
135+
Popup(
136+
alignment = Alignment.TopStart,
137+
offset = offset ,
138+
properties = PopupProperties(focusable = true),
139+
onDismissRequest = { expanded = false }
140+
) {
141+
val density = LocalDensity.current
142+
Column(
143+
modifier = Modifier
144+
.background(backgroundColor, RoundedCornerShape(8.dp))
145+
.padding(8.dp)
146+
.widthIn(max = 50.dp)
147+
.height(animatedHeight)
148+
.scale(animatedScale)
149+
.onGloballyPositioned { coordinates ->
150+
popupHeight = with(density) { coordinates.size.height.toDp() }
151+
}
152+
) {
153+
items.forEach { item ->
154+
Text(
155+
text = item.name,
156+
modifier = Modifier
157+
.fillMaxWidth()
158+
.clickable {
159+
item.select()
160+
expanded = false
161+
}
162+
.padding(8.dp),
163+
color = textColor
164+
)
165+
}
166+
}
167+
}
168+
}
169+
}
170+
171+
@Preview
172+
@Composable
173+
fun CustomDropdownPreview() {
174+
CustomDropdown(
175+
name = "Volume Swipe Speed",
176+
items = listOf(
177+
DropdownItem("Always On") { },
178+
DropdownItem("Off") { },
179+
DropdownItem("Only when speaking") { }
180+
)
181+
)
182+
}

0 commit comments

Comments
 (0)