mirror of
https://github.com/kavishdevar/librepods.git
synced 2026-06-13 03:52:54 +00:00
Compare commits
5 Commits
nightly-04
...
nightly-73
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7341e41837 | ||
|
|
bffb5c8b3e | ||
|
|
aca4373ec4 | ||
|
|
8804197760 | ||
|
|
57d692c4ae |
@@ -1,6 +1,6 @@
|
||||
import java.util.Properties
|
||||
|
||||
val appVersionName = "0.2.9"
|
||||
val appVersionName = "0.3.0"
|
||||
|
||||
plugins {
|
||||
alias(libs.plugins.android.application)
|
||||
@@ -41,7 +41,7 @@ android {
|
||||
defaultConfig {
|
||||
applicationId = "me.kavishdevar.librepods"
|
||||
targetSdk = 37
|
||||
versionCode = 55
|
||||
versionCode = 56
|
||||
versionName = appVersionName
|
||||
}
|
||||
buildTypes {
|
||||
|
||||
@@ -118,6 +118,7 @@ import com.kyant.backdrop.backdrops.rememberLayerBackdrop
|
||||
import dev.chrisbanes.haze.hazeSource
|
||||
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
|
||||
import dev.chrisbanes.haze.rememberHazeState
|
||||
import me.kavishdevar.librepods.bluetooth.BluetoothConnectionManager
|
||||
import me.kavishdevar.librepods.data.AirPodsNotifications
|
||||
import me.kavishdevar.librepods.data.ControlCommandRepository
|
||||
import me.kavishdevar.librepods.presentation.components.AppInfoCard
|
||||
@@ -129,6 +130,7 @@ import me.kavishdevar.librepods.presentation.screens.AirPodsSettingsScreen
|
||||
import me.kavishdevar.librepods.presentation.screens.AppSettingsScreen
|
||||
import me.kavishdevar.librepods.presentation.screens.CameraControlScreen
|
||||
import me.kavishdevar.librepods.presentation.screens.DebugScreen
|
||||
import me.kavishdevar.librepods.presentation.screens.EqualizerScreen
|
||||
import me.kavishdevar.librepods.presentation.screens.HeadTrackingScreen
|
||||
import me.kavishdevar.librepods.presentation.screens.HearingAidAdjustmentsScreen
|
||||
import me.kavishdevar.librepods.presentation.screens.HearingAidScreen
|
||||
@@ -478,6 +480,9 @@ fun Main() {
|
||||
val purchaseViewModel: PurchaseViewModel = viewModel()
|
||||
PurchaseScreen(purchaseViewModel, navController)
|
||||
}
|
||||
composable("equalizer_screen") {
|
||||
if (airPodsViewModel != null) EqualizerScreen(airPodsViewModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -541,7 +546,7 @@ fun Main() {
|
||||
Context.BIND_AUTO_CREATE
|
||||
)
|
||||
|
||||
if (airPodsService.value?.isConnected() == true) {
|
||||
if (BluetoothConnectionManager.getAACPSocket()?.isConnected == true) {
|
||||
isConnected.value = true
|
||||
}
|
||||
} else {
|
||||
|
||||
@@ -21,6 +21,8 @@
|
||||
package me.kavishdevar.librepods.bluetooth
|
||||
|
||||
import android.util.Log
|
||||
import me.kavishdevar.librepods.data.Capability
|
||||
import me.kavishdevar.librepods.data.CustomEq
|
||||
import java.nio.ByteBuffer
|
||||
import java.nio.ByteOrder
|
||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||
@@ -31,9 +33,8 @@ import kotlin.io.encoding.ExperimentalEncodingApi
|
||||
* constructing and parsing packets for communication with AirPods.
|
||||
*/
|
||||
class AACPManager {
|
||||
private val TAG = "AACPManager[${System.identityHashCode(this)}]"
|
||||
companion object {
|
||||
private const val TAG = "AACPManager"
|
||||
|
||||
@Suppress("unused")
|
||||
object Opcodes {
|
||||
const val SET_FEATURE_FLAGS: Byte = 0x4D
|
||||
@@ -48,7 +49,7 @@ class AACPManager {
|
||||
const val PROXIMITY_KEYS_REQ: Byte = 0x30
|
||||
const val PROXIMITY_KEYS_RSP: Byte = 0x31
|
||||
const val STEM_PRESS: Byte = 0x19
|
||||
const val EQ_DATA: Byte = 0x53
|
||||
const val HEADPHONE_ACCOMMODATION: Byte = 0x53
|
||||
const val CONNECTED_DEVICES: Byte = 0x2E // TiPi 1
|
||||
const val AUDIO_SOURCE: Byte = 0x0E // TiPi 2
|
||||
const val SMART_ROUTING: Byte = 0x10
|
||||
@@ -56,6 +57,7 @@ class AACPManager {
|
||||
const val SMART_ROUTING_RESP: Byte = 0x11
|
||||
const val SEND_CONNECTED_MAC: Byte = 0x14
|
||||
const val AUDIO_SOURCE_2: Byte = 0x0C // seems redundant?
|
||||
const val CUSTOM_EQ: Byte = 0x63
|
||||
}
|
||||
|
||||
private val HEADER_BYTES = byteArrayOf(0x04, 0x00, 0x04, 0x00)
|
||||
@@ -200,6 +202,11 @@ class AACPManager {
|
||||
var eqOnMedia: Boolean = false
|
||||
private set
|
||||
|
||||
var customEq: CustomEq = CustomEq(state = 1, low = 50, mid = 50, high = 50)
|
||||
private set
|
||||
|
||||
var customEqCallback: ((CustomEq) -> Unit)? = null
|
||||
|
||||
fun getControlCommandStatus(identifier: ControlCommandIdentifiers): ControlCommandStatus? {
|
||||
return controlCommandStatusList.find { it.identifier == identifier }
|
||||
}
|
||||
@@ -236,7 +243,9 @@ class AACPManager {
|
||||
fun onConnectedDevicesReceived(connectedDevices: List<ConnectedDevice>)
|
||||
fun onOwnershipToFalseRequest(sender: String, reasonReverseTapped: Boolean)
|
||||
fun onShowNearbyUI(sender: String)
|
||||
fun onEQPacketReceived(eqData: FloatArray)
|
||||
fun onHeadphoneAccommodationReceived(eqData: FloatArray)
|
||||
fun onCustomEqReceived(customEq: CustomEq)
|
||||
fun onCapabilitiesReceived(capabilities: List<Capability>)
|
||||
}
|
||||
|
||||
fun parseStemPressResponse(data: ByteArray): Pair<StemPressType, StemPressBudType> {
|
||||
@@ -549,18 +558,18 @@ class AACPManager {
|
||||
}
|
||||
}
|
||||
|
||||
Opcodes.EQ_DATA -> {
|
||||
Opcodes.HEADPHONE_ACCOMMODATION -> {
|
||||
if (packet.size != 140) {
|
||||
Log.w(
|
||||
TAG,
|
||||
"Received EQ_DATA packet of unexpected size: ${packet.size}, expected 140"
|
||||
"Received HEADPHONE_ACCOMMODATION packet of unexpected size: ${packet.size}, expected 140"
|
||||
)
|
||||
return
|
||||
}
|
||||
if (packet[6] != 0x84.toByte()) {
|
||||
Log.w(
|
||||
TAG,
|
||||
"Received EQ_DATA packet with unexpected identifier: ${packet[6].toHexString()}, expected 0x84"
|
||||
"Received HEADPHONE_ACCOMMODATION packet with unexpected identifier: ${packet[6].toHexString()}, expected 0x84"
|
||||
)
|
||||
return
|
||||
}
|
||||
@@ -583,7 +592,7 @@ class AACPManager {
|
||||
"EQ Data set to: ${eqData.toList()}, eqOnPhone: $eqOnPhone, eqOnMedia: $eqOnMedia"
|
||||
)
|
||||
|
||||
callback?.onEQPacketReceived(eqData)
|
||||
callback?.onHeadphoneAccommodationReceived(eqData)
|
||||
}
|
||||
|
||||
Opcodes.INFORMATION -> {
|
||||
@@ -592,6 +601,13 @@ class AACPManager {
|
||||
callback?.onDeviceInformationReceived(information)
|
||||
}
|
||||
|
||||
Opcodes.CUSTOM_EQ -> {
|
||||
Log.d(TAG, "Parsing CUSTOM_EQ: ${packet.toHexString()}")
|
||||
customEq = parseCustomEqPacket(packet)
|
||||
customEqCallback?.invoke(customEq)
|
||||
callback?.onCustomEqReceived(customEq)
|
||||
}
|
||||
|
||||
else -> {
|
||||
Log.d(TAG, "Unhandled opcode received: ${opcode.toHexString()}")
|
||||
callback?.onUnknownPacketReceived(packet)
|
||||
@@ -1297,4 +1313,38 @@ class AACPManager {
|
||||
version3 = strings.getOrNull(10) ?: "",
|
||||
)
|
||||
}
|
||||
|
||||
fun sendCustomEqPacket(customEq: CustomEq): Boolean {
|
||||
return sendDataPacket(customEq.toPacket())
|
||||
}
|
||||
|
||||
fun parseCustomEqPacket(packet: ByteArray): CustomEq {
|
||||
val data = packet.sliceArray(6 until packet.size)
|
||||
|
||||
if (data.size < 7) {
|
||||
Log.e(TAG, "custom EQ packet length less than 7, returning default")
|
||||
return CustomEq(1, 50, 50, 50)
|
||||
}
|
||||
|
||||
val lengthLow = data[0].toInt() and 0xFF
|
||||
val lengthHigh = data[1].toInt() and 0xFF
|
||||
|
||||
val length = (lengthHigh shl 8) or lengthLow
|
||||
|
||||
if (length != 5) {
|
||||
Log.w(TAG, "parseCustomEqPacket: unexpected length ($length). parsing normally")
|
||||
}
|
||||
|
||||
val state = data[3].toInt()
|
||||
val low = data[4].toInt()
|
||||
val mid = data[5].toInt()
|
||||
val high = data[6].toInt()
|
||||
|
||||
return CustomEq(
|
||||
state,
|
||||
low,
|
||||
mid,
|
||||
high
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
package me.kavishdevar.librepods.data
|
||||
|
||||
import me.kavishdevar.librepods.bluetooth.AACPManager
|
||||
|
||||
enum class CustomEqBand { LOW, MID, HIGH }
|
||||
|
||||
data class CustomEq(val state: Int, val low: Int, val mid: Int, val high: Int) {
|
||||
|
||||
fun isEnabled(): Boolean {
|
||||
return state == 2
|
||||
}
|
||||
|
||||
fun toPacket(): ByteArray {
|
||||
return byteArrayOf(
|
||||
AACPManager.Companion.Opcodes.CUSTOM_EQ, 0x00,
|
||||
0x05, 0x00, // length (LE)
|
||||
0x01, state.toByte(),
|
||||
low.toByte(), mid.toByte(), high.toByte()
|
||||
)
|
||||
}
|
||||
|
||||
init {
|
||||
require(low in 0..100) { "low must be between 0 and 100, was $low" }
|
||||
require(mid in 0..100) { "mid must be between 0 and 100, was $mid" }
|
||||
require(high in 0..100) { "high must be between 0 and 100, was $high" }
|
||||
}
|
||||
}
|
||||
@@ -22,8 +22,10 @@ import android.os.Parcelable
|
||||
import android.util.Log
|
||||
import kotlinx.parcelize.Parcelize
|
||||
|
||||
// TODO: Remove everything but Battery-related stuff
|
||||
|
||||
enum class Enums(val value: ByteArray) {
|
||||
NOISE_CANCELLATION(Capabilities.NOISE_CANCELLATION),
|
||||
NOISE_CANCELLATION(byteArrayOf(0x0d)),
|
||||
PREFIX(byteArrayOf(0x04, 0x00, 0x04, 0x00)),
|
||||
SETTINGS(byteArrayOf(0x09, 0x00)),
|
||||
NOISE_CANCELLATION_PREFIX(PREFIX.value + SETTINGS.value + NOISE_CANCELLATION.value),
|
||||
@@ -81,12 +83,12 @@ class AirPodsNotifications {
|
||||
const val AIRPODS_DISCONNECTED = "me.kavishdevar.librepods.AIRPODS_DISCONNECTED"
|
||||
const val AIRPODS_CONNECTION_DETECTED = "me.kavishdevar.librepods.AIRPODS_CONNECTION_DETECTED"
|
||||
const val DISCONNECT_RECEIVERS = "me.kavishdevar.librepods.DISCONNECT_RECEIVERS"
|
||||
const val EQ_DATA = "me.kavishdevar.librepods.EQ_DATA"
|
||||
const val EQ_DATA = "me.kavishdevar.librepods.HEADPHONE_ACCOMMODATION"
|
||||
const val AIRPODS_INFORMATION_UPDATED = "me.kavishdevar.librepods.AIRPODS_INFORMATION_UPDATED"
|
||||
}
|
||||
|
||||
class EarDetection {
|
||||
private val notificationBit = Capabilities.EAR_DETECTION
|
||||
private val notificationBit = 6.toByte()
|
||||
private val notificationPrefix = Enums.PREFIX.value + notificationBit
|
||||
|
||||
var status: List<Byte> = listOf(0x01, 0x01)
|
||||
@@ -243,13 +245,6 @@ class AirPodsNotifications {
|
||||
}
|
||||
}
|
||||
|
||||
class Capabilities {
|
||||
companion object {
|
||||
val NOISE_CANCELLATION = byteArrayOf(0x0d)
|
||||
val EAR_DETECTION = byteArrayOf(0x06)
|
||||
}
|
||||
}
|
||||
|
||||
fun isHeadTrackingData(data: ByteArray): Boolean {
|
||||
if (data.size <= 60) return false
|
||||
|
||||
|
||||
@@ -53,6 +53,7 @@ fun AudioSettings(
|
||||
conversationalAwarenessCapability: Boolean,
|
||||
loudSoundReductionCapability: Boolean,
|
||||
adaptiveAudioCapability: Boolean,
|
||||
customEqCapability: Boolean,
|
||||
|
||||
adaptiveVolumeChecked: Boolean,
|
||||
onAdaptiveVolumeCheckedChange: (Boolean) -> Unit,
|
||||
@@ -157,6 +158,20 @@ fun AudioSettings(
|
||||
navController = navController,
|
||||
independent = false
|
||||
)
|
||||
HorizontalDivider(
|
||||
thickness = 1.dp,
|
||||
color = Color(0x40888888),
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 12.dp)
|
||||
)
|
||||
}
|
||||
if (customEqCapability) {
|
||||
NavigationButton(
|
||||
to = "equalizer_screen",
|
||||
name = stringResource(R.string.equalizer),
|
||||
navController = navController,
|
||||
independent = false
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -170,6 +185,7 @@ fun AudioSettingsPreview() {
|
||||
conversationalAwarenessCapability = true,
|
||||
loudSoundReductionCapability = true,
|
||||
adaptiveAudioCapability = true,
|
||||
customEqCapability = true,
|
||||
adaptiveVolumeChecked = true,
|
||||
onAdaptiveVolumeCheckedChange = { },
|
||||
conversationalAwarenessChecked = true,
|
||||
|
||||
@@ -140,7 +140,7 @@ half4 main(float2 coord) {
|
||||
}
|
||||
drawRect(color)
|
||||
} else {
|
||||
if (isPressed) {
|
||||
if (isPressed && enabled) {
|
||||
drawRect(Color.Black.copy(alpha = 0.4f))
|
||||
drawRect(Color.White.copy(alpha = 0.2f))
|
||||
}
|
||||
@@ -264,29 +264,38 @@ half4 main(float2 coord) {
|
||||
val progressAnimationSpec = spring(0.5f, 300f, 0.001f)
|
||||
val offsetAnimationSpec = spring(1f, 300f, Offset.VisibilityThreshold)
|
||||
val onDragStop: () -> Unit = {
|
||||
scope.launch {
|
||||
launch { haptics.performHapticFeedback(HapticFeedbackType.Reject) }
|
||||
launch { progressAnimation.animateTo(0f, progressAnimationSpec) }
|
||||
launch {
|
||||
offsetAnimation.animateTo(
|
||||
Offset.Zero,
|
||||
offsetAnimationSpec
|
||||
)
|
||||
if (enabled) {
|
||||
scope.launch {
|
||||
launch { haptics.performHapticFeedback(HapticFeedbackType.Reject) }
|
||||
launch {
|
||||
progressAnimation.animateTo(
|
||||
0f,
|
||||
progressAnimationSpec
|
||||
)
|
||||
}
|
||||
launch {
|
||||
offsetAnimation.animateTo(
|
||||
Offset.Zero,
|
||||
offsetAnimationSpec
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
inspectDragGestures(
|
||||
onDragStart = { down ->
|
||||
pressStartPosition = down.position
|
||||
scope.launch {
|
||||
launch { haptics.performHapticFeedback(HapticFeedbackType.SegmentFrequentTick) }
|
||||
launch {
|
||||
progressAnimation.animateTo(
|
||||
1f,
|
||||
progressAnimationSpec
|
||||
)
|
||||
if (enabled) {
|
||||
scope.launch {
|
||||
launch { haptics.performHapticFeedback(HapticFeedbackType.SegmentFrequentTick) }
|
||||
launch {
|
||||
progressAnimation.animateTo(
|
||||
1f,
|
||||
progressAnimationSpec
|
||||
)
|
||||
}
|
||||
launch { offsetAnimation.snapTo(Offset.Zero) }
|
||||
}
|
||||
launch { offsetAnimation.snapTo(Offset.Zero) }
|
||||
}
|
||||
},
|
||||
onDragEnd = {
|
||||
@@ -294,11 +303,13 @@ half4 main(float2 coord) {
|
||||
},
|
||||
onDragCancel = onDragStop
|
||||
) { _, dragAmount ->
|
||||
scope.launch {
|
||||
if (dragAmount.getDistanceSquared() > 350) haptics.performHapticFeedback(
|
||||
HapticFeedbackType.SegmentFrequentTick
|
||||
)
|
||||
offsetAnimation.snapTo(offsetAnimation.value + dragAmount)
|
||||
if (enabled) {
|
||||
scope.launch {
|
||||
if (dragAmount.getDistanceSquared() > 350) haptics.performHapticFeedback(
|
||||
HapticFeedbackType.SegmentFrequentTick
|
||||
)
|
||||
offsetAnimation.snapTo(offsetAnimation.value + dragAmount)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -365,6 +365,7 @@ fun AirPodsSettingsScreen(viewModel: AirPodsViewModel, navController: NavControl
|
||||
conversationalAwarenessCapability = conversationalAwarenessCapability,
|
||||
loudSoundReductionCapability = loudSoundReductionCapability,
|
||||
adaptiveAudioCapability = adaptiveAudioCapability,
|
||||
customEqCapability = true,
|
||||
adaptiveVolumeChecked = adaptiveVolumeChecked,
|
||||
onAdaptiveVolumeCheckedChange = { checked ->
|
||||
viewModel.setControlCommandBoolean(
|
||||
|
||||
@@ -0,0 +1,658 @@
|
||||
/*
|
||||
LibrePods - AirPods liberated from Apple’s ecosystem
|
||||
Copyright (C) 2025 LibrePods contributors
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
any later version.
|
||||
|
||||
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 General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package me.kavishdevar.librepods.presentation.screens
|
||||
|
||||
import androidx.compose.animation.Crossfade
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.gestures.Orientation
|
||||
import androidx.compose.foundation.gestures.draggable
|
||||
import androidx.compose.foundation.gestures.rememberDraggableState
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.visible
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.Path
|
||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||
import androidx.compose.ui.graphics.lerp
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.Font
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.util.lerp
|
||||
import com.kyant.backdrop.backdrops.layerBackdrop
|
||||
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
|
||||
import com.kyant.backdrop.drawBackdrop
|
||||
import com.kyant.backdrop.effects.lens
|
||||
import com.kyant.backdrop.highlight.Highlight
|
||||
import kotlinx.coroutines.FlowPreview
|
||||
import kotlinx.coroutines.flow.debounce
|
||||
import me.kavishdevar.librepods.R
|
||||
import me.kavishdevar.librepods.presentation.components.SelectItem
|
||||
import me.kavishdevar.librepods.presentation.components.StyledButton
|
||||
import me.kavishdevar.librepods.presentation.components.StyledScaffold
|
||||
import me.kavishdevar.librepods.presentation.components.StyledSelectList
|
||||
import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.roundToInt
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
@OptIn(FlowPreview::class)
|
||||
@Composable
|
||||
fun EqualizerScreen(viewModel: AirPodsViewModel) {
|
||||
val state by viewModel.uiState.collectAsState()
|
||||
|
||||
val customEq = state.customEq
|
||||
val enabled = customEq.isEnabled()
|
||||
|
||||
val recommendedString = stringResource(R.string.recommended)
|
||||
val customString = stringResource(R.string.custom)
|
||||
|
||||
val eqStateOptions = remember(state.customEq) {
|
||||
listOf(
|
||||
SelectItem(
|
||||
name = recommendedString,
|
||||
selected = !enabled,
|
||||
onClick = { viewModel.setCustomEqEnabled(false) }
|
||||
),
|
||||
SelectItem(
|
||||
name = customString,
|
||||
selected = enabled,
|
||||
onClick = { viewModel.setCustomEqEnabled(true) }
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
StyledScaffold(
|
||||
title = stringResource(R.string.equalizer)
|
||||
) { spacerHeight ->
|
||||
val scrollState = rememberScrollState()
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(horizontal = 16.dp)
|
||||
.verticalScroll(scrollState),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
|
||||
val height = 200.dp
|
||||
val maxOffset = with(LocalDensity.current) { height.toPx() } / 2
|
||||
|
||||
val offsets = remember(state.customEq) {
|
||||
listOf(
|
||||
mutableFloatStateOf(lerp(maxOffset, -maxOffset, customEq.low.toFloat() / 100)),
|
||||
mutableFloatStateOf(lerp(maxOffset, -maxOffset, customEq.mid.toFloat() / 100)),
|
||||
mutableFloatStateOf(lerp(maxOffset, -maxOffset, customEq.high.toFloat() / 100))
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(spacerHeight))
|
||||
StyledSelectList(items = eqStateOptions)
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
val backgroundColor = if (isSystemInDarkTheme()) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
|
||||
|
||||
Crossfade (
|
||||
customEq.isEnabled()
|
||||
) { visible ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.visible(visible),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(backgroundColor, RoundedCornerShape(28.dp))
|
||||
) {
|
||||
val dashColor =
|
||||
if (isSystemInDarkTheme()) Color(0x80AAAAAA) else Color(0x809D9D9D)
|
||||
// LaunchedEffect(offsets[0].floatValue, offsets[1].floatValue, offsets[2].floatValue) {
|
||||
// val low = ((offsets[0].floatValue / (2 * maxOffset) + 0.5f) * 100).roundToInt()
|
||||
// val mid = ((offsets[1].floatValue / (2 * maxOffset) + 0.5f) * 100).roundToInt()
|
||||
// val high = ((offsets[2].floatValue / (2 * maxOffset) + 0.5f) * 100).roundToInt()
|
||||
// Log.d("EqualizerScreen", "$low, $mid, $high")
|
||||
// viewModel.setCustomEq(
|
||||
// low = low,
|
||||
// mid = mid,
|
||||
// high = high
|
||||
// )
|
||||
// }
|
||||
|
||||
LaunchedEffect(offsets) {
|
||||
snapshotFlow {
|
||||
Triple(
|
||||
offsets[0].floatValue,
|
||||
offsets[1].floatValue,
|
||||
offsets[2].floatValue
|
||||
)
|
||||
}
|
||||
.debounce(100.milliseconds) // cool, should've been using this since the very beginning
|
||||
.collect { (lowF, midF, highF) ->
|
||||
val low =
|
||||
100 - ((lowF / (2 * maxOffset) + 0.5f) * 100).roundToInt()
|
||||
val mid =
|
||||
100 - ((midF / (2 * maxOffset) + 0.5f) * 100).roundToInt()
|
||||
val high =
|
||||
100 - ((highF / (2 * maxOffset) + 0.5f) * 100).roundToInt()
|
||||
|
||||
viewModel.setCustomEq(low, mid, high)
|
||||
}
|
||||
}
|
||||
|
||||
val backdrop = rememberLayerBackdrop()
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(backgroundColor, RoundedCornerShape(28.dp))
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(42.dp))
|
||||
// Row(
|
||||
// modifier = Modifier
|
||||
// .fillMaxWidth()
|
||||
// .padding(18.dp),
|
||||
// verticalAlignment = Alignment.CenterVertically,
|
||||
// horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||
// ) {
|
||||
// Box(
|
||||
// modifier = Modifier
|
||||
// .size(64.dp)
|
||||
// .background(if (isSystemInDarkTheme()) Color.DarkGray else Color.LightGray, RoundedCornerShape(12.dp))
|
||||
// )
|
||||
// Column(
|
||||
// modifier = Modifier
|
||||
// .weight(1f),
|
||||
// verticalArrangement = Arrangement.Center
|
||||
// ) {
|
||||
// Text(
|
||||
// text = "Written into Changes",
|
||||
// style = TextStyle(
|
||||
// fontSize = 16.sp,
|
||||
// fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||
// fontWeight = FontWeight.Bold,
|
||||
// color = if (isSystemInDarkTheme()) Color.White else Color.Black
|
||||
// )
|
||||
// )
|
||||
// Spacer(modifier = Modifier.height(4.dp))
|
||||
// Text(
|
||||
// text = "Avalon Emerson",
|
||||
// style = TextStyle(
|
||||
// fontSize = 14.sp,
|
||||
// fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||
// fontWeight = FontWeight.Normal,
|
||||
// color = if (isSystemInDarkTheme()) Color.White else Color.Black
|
||||
// )
|
||||
// )
|
||||
// }
|
||||
// val paused = remember { mutableStateOf(false) }
|
||||
// Box(
|
||||
// modifier = Modifier
|
||||
// .size(48.dp)
|
||||
// .background(Color(0x600091FF), CircleShape)
|
||||
// .clickable(
|
||||
// interactionSource = remember { MutableInteractionSource() },
|
||||
// indication = null,
|
||||
// ) {
|
||||
// paused.value = !paused.value
|
||||
// },
|
||||
// contentAlignment = Alignment.Center
|
||||
// ) {
|
||||
// Crossfade(
|
||||
// targetState = paused.value,
|
||||
// label = "media_icon"
|
||||
// ) { p ->
|
||||
// Text(
|
||||
// text = if (p) "" else "",
|
||||
// style = TextStyle(
|
||||
// fontSize = 24.sp,
|
||||
// fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||
// fontWeight = FontWeight.Normal,
|
||||
// color = Color(0xFF0091FF),
|
||||
// textAlign = TextAlign.Center
|
||||
// )
|
||||
// )
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// HorizontalDivider(
|
||||
// thickness = 1.dp,
|
||||
// color = Color(0x40888888),
|
||||
// modifier = Modifier
|
||||
// .padding(horizontal = 20.dp)
|
||||
// .padding(bottom = 16.dp)
|
||||
// )
|
||||
|
||||
Box(
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
fun colorFromY(y: Float): Color {
|
||||
val f = ((y + maxOffset) / (2f * maxOffset)).coerceIn(0f, 1f)
|
||||
val stops = listOf(
|
||||
0.0f to Color(0xFFFFA300),
|
||||
0.25f to Color(0xFFFCE600),
|
||||
0.5f to Color(0xFF00FAAF),
|
||||
0.75f to Color(0xFF00FAFF),
|
||||
1.0f to Color(0xFF00B5FF)
|
||||
)
|
||||
val (start, end) = stops.zipWithNext()
|
||||
.first { f <= it.second.first }
|
||||
val c = (f - start.first) / (end.first - start.first)
|
||||
return lerp(start.second, end.second, c)
|
||||
}
|
||||
|
||||
fun pathBrush(
|
||||
startY: Float,
|
||||
endY: Float,
|
||||
): Brush {
|
||||
val stops = (0..20).map { i ->
|
||||
val t = i / 20f
|
||||
val y = lerp(startY, endY, t)
|
||||
t to colorFromY(y)
|
||||
}
|
||||
|
||||
return Brush.linearGradient(
|
||||
colorStops = stops.toTypedArray()
|
||||
)
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth().layerBackdrop(backdrop)
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(height)
|
||||
.padding(horizontal = 20.dp)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
) {
|
||||
val dashCount = (height / 10.dp).toInt()
|
||||
repeat(3) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.weight(1f),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxHeight(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp)
|
||||
) {
|
||||
for (i in 1..(dashCount)) {
|
||||
val t = i.toFloat() / dashCount
|
||||
val centerDistance = abs(0.5f - t)
|
||||
val alpha = 1f - (centerDistance * 2f)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.height(9.dp)
|
||||
.width(0.75.dp)
|
||||
.background(
|
||||
dashColor.copy(alpha),
|
||||
RoundedCornerShape(28.dp)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Canvas(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
) {
|
||||
val canvasWidth = size.width
|
||||
|
||||
drawLine(
|
||||
color = backgroundColor,
|
||||
start = Offset(
|
||||
x = 0f,
|
||||
y = offsets[0].floatValue + maxOffset
|
||||
),
|
||||
end = Offset(
|
||||
x = 1 / 6f * canvasWidth,
|
||||
y = offsets[0].floatValue + maxOffset
|
||||
),
|
||||
strokeWidth = 10f
|
||||
)
|
||||
drawLine(
|
||||
color = colorFromY(offsets[0].floatValue),
|
||||
start = Offset(
|
||||
x = 0f,
|
||||
y = offsets[0].floatValue + maxOffset
|
||||
),
|
||||
end = Offset(
|
||||
x = 1 / 6f * canvasWidth,
|
||||
y = offsets[0].floatValue + maxOffset
|
||||
),
|
||||
strokeWidth = 8f
|
||||
)
|
||||
|
||||
val lowToMidPath = Path()
|
||||
lowToMidPath.moveTo(
|
||||
x = 1 / 6f * canvasWidth,
|
||||
y = offsets[0].floatValue + maxOffset
|
||||
)
|
||||
lowToMidPath.cubicTo(
|
||||
x1 = canvasWidth * 1 / 6f + 108.dp.value,
|
||||
y1 = offsets[0].floatValue + maxOffset,
|
||||
x2 = canvasWidth * 0.5f - 108.dp.value,
|
||||
y2 = offsets[1].floatValue + maxOffset,
|
||||
x3 = canvasWidth * 0.5f,
|
||||
y3 = offsets[1].floatValue + maxOffset
|
||||
)
|
||||
drawPath(
|
||||
color = backgroundColor,
|
||||
path = lowToMidPath,
|
||||
style = Stroke(width = 10f)
|
||||
)
|
||||
drawPath(
|
||||
brush = pathBrush(
|
||||
offsets[0].floatValue,
|
||||
offsets[1].floatValue
|
||||
),
|
||||
path = lowToMidPath,
|
||||
style = Stroke(width = 8f)
|
||||
)
|
||||
|
||||
val midToHighPath = Path()
|
||||
midToHighPath.moveTo(
|
||||
x = 0.5f * canvasWidth,
|
||||
y = offsets[1].floatValue + maxOffset
|
||||
)
|
||||
midToHighPath.cubicTo(
|
||||
x1 = canvasWidth * 0.5f + 108.dp.value,
|
||||
y1 = offsets[1].floatValue + maxOffset,
|
||||
x2 = canvasWidth * 5 / 6f - 108.dp.value,
|
||||
y2 = offsets[2].floatValue + maxOffset,
|
||||
x3 = canvasWidth * 5 / 6f,
|
||||
y3 = offsets[2].floatValue + maxOffset
|
||||
)
|
||||
drawPath(
|
||||
color = backgroundColor,
|
||||
path = midToHighPath,
|
||||
style = Stroke(width = 10f)
|
||||
)
|
||||
drawPath(
|
||||
brush = pathBrush(
|
||||
offsets[1].floatValue,
|
||||
offsets[2].floatValue
|
||||
),
|
||||
path = midToHighPath,
|
||||
style = Stroke(width = 8f)
|
||||
)
|
||||
drawLine(
|
||||
color = backgroundColor,
|
||||
start = Offset(
|
||||
x = 5 / 6f * canvasWidth,
|
||||
y = offsets[2].floatValue + maxOffset
|
||||
),
|
||||
end = Offset(
|
||||
x = 1f * canvasWidth,
|
||||
y = offsets[2].floatValue + maxOffset
|
||||
),
|
||||
strokeWidth = 10f
|
||||
)
|
||||
drawLine(
|
||||
color = colorFromY(offsets[2].floatValue),
|
||||
start = Offset(
|
||||
x = 5 / 6f * canvasWidth,
|
||||
y = offsets[2].floatValue + maxOffset
|
||||
),
|
||||
end = Offset(
|
||||
x = 1f * canvasWidth,
|
||||
y = offsets[2].floatValue + maxOffset
|
||||
),
|
||||
strokeWidth = 8f
|
||||
)
|
||||
}
|
||||
}
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 16.dp, horizontal = 20.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier.weight(1f)
|
||||
) {
|
||||
Text(
|
||||
text = "Low".uppercase(),
|
||||
style = TextStyle(
|
||||
fontSize = 14.sp,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = (if (isSystemInDarkTheme()) Color.White else Color.Black).copy(
|
||||
0.2f
|
||||
),
|
||||
textAlign = TextAlign.Center
|
||||
),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier.weight(1f)
|
||||
) {
|
||||
Text(
|
||||
text = "Mid".uppercase(),
|
||||
style = TextStyle(
|
||||
fontSize = 14.sp,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = (if (isSystemInDarkTheme()) Color.White else Color.Black).copy(
|
||||
0.2f
|
||||
),
|
||||
textAlign = TextAlign.Center
|
||||
),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier.weight(1f)
|
||||
) {
|
||||
Text(
|
||||
text = "High".uppercase(),
|
||||
style = TextStyle(
|
||||
fontSize = 14.sp,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = (if (isSystemInDarkTheme()) Color.White else Color.Black).copy(
|
||||
0.2f
|
||||
),
|
||||
textAlign = TextAlign.Center
|
||||
),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
}
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(height)
|
||||
.padding(horizontal = 20.dp),
|
||||
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
for (i in 0..2) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.weight(1f),
|
||||
horizontalArrangement = Arrangement.Center
|
||||
) {
|
||||
val pressed = remember { mutableStateOf(false) }
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.offset {
|
||||
IntOffset(
|
||||
x = 0,
|
||||
y = offsets[i].floatValue.roundToInt()
|
||||
)
|
||||
},
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Crossfade(
|
||||
pressed.value
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(96.dp)
|
||||
.then(
|
||||
if (it) {
|
||||
Modifier.drawBackdrop(
|
||||
backdrop = backdrop,
|
||||
shape = { CircleShape },
|
||||
highlight = {
|
||||
Highlight.Ambient
|
||||
},
|
||||
onDrawSurface = {
|
||||
drawCircle(
|
||||
color = Color.White.copy(
|
||||
0.2f
|
||||
),
|
||||
radius = size.height
|
||||
)
|
||||
drawCircle(
|
||||
color = colorFromY(
|
||||
offsets[i].floatValue
|
||||
),
|
||||
style = Stroke(2.dp.value),
|
||||
radius = size.height / 2
|
||||
)
|
||||
},
|
||||
effects = {
|
||||
lens(
|
||||
refractionHeight = 32f.dp.value,
|
||||
refractionAmount = size.height
|
||||
)
|
||||
}
|
||||
)
|
||||
} else Modifier
|
||||
)
|
||||
)
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(18.dp)
|
||||
.background(
|
||||
colorFromY(offsets[i].floatValue),
|
||||
CircleShape
|
||||
)
|
||||
.border(
|
||||
2.5.dp,
|
||||
backgroundColor,
|
||||
CircleShape
|
||||
)
|
||||
.draggable(
|
||||
orientation = Orientation.Vertical,
|
||||
state = rememberDraggableState { delta ->
|
||||
offsets[i].floatValue =
|
||||
(offsets[i].floatValue + delta).coerceIn(
|
||||
-maxOffset,
|
||||
maxOffset
|
||||
)
|
||||
},
|
||||
onDragStarted = {
|
||||
pressed.value = true
|
||||
},
|
||||
onDragStopped = {
|
||||
pressed.value = false
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val resetButtonEnabled = remember { derivedStateOf { !offsets.all { it.floatValue == 0f } } }
|
||||
|
||||
StyledButton(
|
||||
onClick = {
|
||||
offsets[0].floatValue = 0f
|
||||
offsets[1].floatValue = 0f
|
||||
offsets[2].floatValue = 0f
|
||||
},
|
||||
backdrop = rememberLayerBackdrop(),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
isInteractive = false,
|
||||
surfaceColor = backgroundColor,
|
||||
enabled = resetButtonEnabled.value
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.reset),
|
||||
style = TextStyle(
|
||||
fontSize = 14.sp,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||
fontWeight = FontWeight.Normal,
|
||||
color = if (!offsets.all { it.floatValue == 0f }) Color(0xFF0093FF) else Color.Gray
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,7 @@ import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.content.SharedPreferences
|
||||
import android.content.pm.PackageManager
|
||||
import android.util.Log
|
||||
import android.widget.Toast
|
||||
import androidx.core.content.edit
|
||||
import androidx.lifecycle.ViewModel
|
||||
@@ -40,6 +41,7 @@ import me.kavishdevar.librepods.bluetooth.AACPManager
|
||||
import me.kavishdevar.librepods.bluetooth.AACPManager.Companion.ControlCommandIdentifiers
|
||||
import me.kavishdevar.librepods.bluetooth.ATTCCCDHandles
|
||||
import me.kavishdevar.librepods.bluetooth.ATTHandles
|
||||
import me.kavishdevar.librepods.bluetooth.BluetoothConnectionManager
|
||||
import me.kavishdevar.librepods.data.AirPodsInstance
|
||||
import me.kavishdevar.librepods.data.AirPodsModels
|
||||
import me.kavishdevar.librepods.data.AirPodsNotifications
|
||||
@@ -48,6 +50,7 @@ import me.kavishdevar.librepods.data.BatteryComponent
|
||||
import me.kavishdevar.librepods.data.BatteryStatus
|
||||
import me.kavishdevar.librepods.data.Capability
|
||||
import me.kavishdevar.librepods.data.ControlCommandRepository
|
||||
import me.kavishdevar.librepods.data.CustomEq
|
||||
import me.kavishdevar.librepods.data.StemAction
|
||||
import me.kavishdevar.librepods.data.XposedRemotePrefProvider
|
||||
import me.kavishdevar.librepods.services.AirPodsService
|
||||
@@ -95,7 +98,9 @@ data class AirPodsUiState(
|
||||
val dynamicEndOfCharge: Boolean = false,
|
||||
|
||||
val connectionSuccessful: Boolean = false,
|
||||
val timeUntilFOSSPremiumExpiry: Long = 0L
|
||||
val timeUntilFOSSPremiumExpiry: Long = 0L,
|
||||
|
||||
val customEq: CustomEq = CustomEq(1, 50, 50, 50) // disabled
|
||||
)
|
||||
|
||||
class AirPodsViewModel(
|
||||
@@ -138,13 +143,36 @@ class AirPodsViewModel(
|
||||
_cameraAction.value = action
|
||||
}
|
||||
|
||||
fun setCustomEq(low: Int, mid: Int, high: Int) {
|
||||
require(low in 0..100)
|
||||
require(mid in 0..100)
|
||||
require(high in 0..100)
|
||||
val updatedEq = _uiState.value.customEq.copy(low = low, mid = mid, high = high)
|
||||
service.aacpManager.sendCustomEqPacket(updatedEq)
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
customEq = updatedEq
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun setCustomEqEnabled(enabled: Boolean) {
|
||||
service.aacpManager.sendCustomEqPacket(_uiState.value.customEq.copy(state = if (enabled) 2 else 1))
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
customEq = it.customEq.copy(state = if (enabled) 2 else 1)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
init {
|
||||
observeBroadcasts()
|
||||
loadName()
|
||||
loadInstance()
|
||||
loadSharedPreferences()
|
||||
setupControlObservers()
|
||||
observeAACP()
|
||||
loadControlList()
|
||||
loadEq()
|
||||
loadATT()
|
||||
observeATT()
|
||||
observeSharedPreferences()
|
||||
@@ -156,7 +184,7 @@ class AirPodsViewModel(
|
||||
listeners.forEach { (id, listener) ->
|
||||
controlRepo.remove(id, listener)
|
||||
}
|
||||
|
||||
service.aacpManager.customEqCallback = null
|
||||
appContext.unregisterReceiver(broadcastReceiver)
|
||||
|
||||
super.onCleared()
|
||||
@@ -313,7 +341,7 @@ class AirPodsViewModel(
|
||||
}
|
||||
|
||||
// I'm lazy, sorry.
|
||||
fun setupControlObservers() {
|
||||
fun observeAACP() {
|
||||
val identifiersList = listOf(
|
||||
ControlCommandIdentifiers.MIC_MODE,
|
||||
ControlCommandIdentifiers.DOUBLE_CLICK_INTERVAL,
|
||||
@@ -345,6 +373,9 @@ class AirPodsViewModel(
|
||||
for (identifier in identifiersList) {
|
||||
observeControl(identifier)
|
||||
}
|
||||
service.aacpManager.customEqCallback = { customEq ->
|
||||
_uiState.update { it.copy(customEq = customEq) }
|
||||
}
|
||||
}
|
||||
|
||||
fun refreshInitialData() {
|
||||
@@ -352,7 +383,7 @@ class AirPodsViewModel(
|
||||
service.let { service ->
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isLocallyConnected = service.isConnected(), battery = service.getBattery()
|
||||
isLocallyConnected = BluetoothConnectionManager.getAACPSocket()?.isConnected == true, battery = service.getBattery()
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -382,7 +413,6 @@ class AirPodsViewModel(
|
||||
|
||||
val connectionSuccessful = sharedPreferences.getBoolean("connection_successful", false)
|
||||
|
||||
val fossUpgraded = sharedPreferences.getBoolean("foss_upgraded", false)
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
offListeningMode = offListeningModeEnabled,
|
||||
@@ -398,8 +428,8 @@ class AirPodsViewModel(
|
||||
}
|
||||
|
||||
// faulty update on Play caused PLAY_BUILD to be false and resulted in use of FOSS billing in Play. since FOSS is not verified, we need to give 2 weeks to verify the purchase
|
||||
|
||||
if (BuildConfig.PLAY_BUILD) {
|
||||
val fossUpgraded = sharedPreferences.getBoolean("foss_upgraded", false)
|
||||
val expiryTime = sharedPreferences.getLong("premium_expiry_time", 0L)
|
||||
val now = System.currentTimeMillis()
|
||||
|
||||
@@ -478,6 +508,14 @@ class AirPodsViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadEq() {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
customEq = service.aacpManager.customEq
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadInstance() {
|
||||
val instance = service.airpodsInstance ?: AirPodsInstance(
|
||||
name = "AirPods",
|
||||
|
||||
@@ -35,9 +35,10 @@ import android.util.Log
|
||||
import androidx.annotation.RequiresApi
|
||||
import me.kavishdevar.librepods.QuickSettingsDialogActivity
|
||||
import me.kavishdevar.librepods.R
|
||||
import me.kavishdevar.librepods.bluetooth.AACPManager
|
||||
import me.kavishdevar.librepods.bluetooth.BluetoothConnectionManager
|
||||
import me.kavishdevar.librepods.data.AirPodsNotifications
|
||||
import me.kavishdevar.librepods.data.NoiseControlMode
|
||||
import me.kavishdevar.librepods.bluetooth.AACPManager
|
||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.Q)
|
||||
@@ -98,7 +99,7 @@ class AirPodsQSService : TileService() {
|
||||
Log.d("AirPodsQSService", "onStartListening")
|
||||
|
||||
val service = ServiceManager.getService()
|
||||
isAirPodsConnected = service?.isConnected() == true
|
||||
isAirPodsConnected = BluetoothConnectionManager.getAACPSocket()?.isConnected == true
|
||||
currentAncMode = service?.getANC() ?: (NoiseControlMode.OFF.ordinal + 1)
|
||||
|
||||
if (currentAncMode == NoiseControlMode.OFF.ordinal + 1 && !isOffModeEnabled()) {
|
||||
|
||||
@@ -96,6 +96,8 @@ import me.kavishdevar.librepods.data.AirPodsNotifications
|
||||
import me.kavishdevar.librepods.data.Battery
|
||||
import me.kavishdevar.librepods.data.BatteryComponent
|
||||
import me.kavishdevar.librepods.data.BatteryStatus
|
||||
import me.kavishdevar.librepods.data.Capability
|
||||
import me.kavishdevar.librepods.data.CustomEq
|
||||
import me.kavishdevar.librepods.data.StemAction
|
||||
import me.kavishdevar.librepods.data.XposedRemotePrefProvider
|
||||
import me.kavishdevar.librepods.data.isHeadTrackingData
|
||||
@@ -233,8 +235,6 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
|
||||
|
||||
lateinit var bleManager: BLEManager
|
||||
|
||||
private lateinit var socket: BluetoothSocket
|
||||
|
||||
companion object {
|
||||
init {
|
||||
System.loadLibrary("bluetooth_socket")
|
||||
@@ -246,7 +246,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
|
||||
override fun onDeviceStatusChanged(
|
||||
device: BLEManager.AirPodsStatus, previousStatus: BLEManager.AirPodsStatus?
|
||||
) {
|
||||
if (device.connectionState == "Disconnected" && !isConnected()) { // should never happen unless android messes up and sends us a stale broadcast
|
||||
if (device.connectionState == "Disconnected" && BluetoothConnectionManager.getAACPSocket()?.isConnected != true) { // should never happen unless android messes up and sends us a stale broadcast
|
||||
Log.d(TAG, "Seems no device has taken over, we will.")
|
||||
val bluetoothManager = getSystemService(BluetoothManager::class.java)
|
||||
val bluetoothAdapter = bluetoothManager.adapter
|
||||
@@ -258,7 +258,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
|
||||
connectToSocket(bluetoothAdapter, bluetoothDevice)
|
||||
}
|
||||
Log.d(TAG, "Device status changed")
|
||||
if (socket.isConnected) return
|
||||
if (BluetoothConnectionManager.getAACPSocket()?.isConnected == true) return
|
||||
val leftLevel = bleManager.getMostRecentStatus()?.leftBattery ?: 0
|
||||
val rightLevel = bleManager.getMostRecentStatus()?.rightBattery ?: 0
|
||||
val caseLevel = bleManager.getMostRecentStatus()?.caseBattery ?: 0
|
||||
@@ -291,7 +291,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
|
||||
getSharedPreferences("settings", MODE_PRIVATE).getString("name", "AirPods Pro")
|
||||
?: "AirPods"
|
||||
)
|
||||
if (socket.isConnected) return
|
||||
if (BluetoothConnectionManager.getAACPSocket()?.isConnected == true) return
|
||||
val leftLevel = bleManager.getMostRecentStatus()?.leftBattery ?: 0
|
||||
val rightLevel = bleManager.getMostRecentStatus()?.rightBattery ?: 0
|
||||
val caseLevel = bleManager.getMostRecentStatus()?.caseBattery ?: 0
|
||||
@@ -325,7 +325,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
|
||||
}
|
||||
|
||||
override fun onBatteryChanged(device: BLEManager.AirPodsStatus) {
|
||||
if (socket.isConnected) return
|
||||
if (BluetoothConnectionManager.getAACPSocket()?.isConnected == true) return
|
||||
val leftLevel = bleManager.getMostRecentStatus()?.leftBattery ?: 0
|
||||
val rightLevel = bleManager.getMostRecentStatus()?.rightBattery ?: 0
|
||||
val caseLevel = bleManager.getMostRecentStatus()?.caseBattery ?: 0
|
||||
@@ -1161,13 +1161,21 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
|
||||
}
|
||||
}
|
||||
|
||||
override fun onEQPacketReceived(eqData: FloatArray) {
|
||||
override fun onHeadphoneAccommodationReceived(eqData: FloatArray) {
|
||||
sendBroadcast(
|
||||
Intent(AirPodsNotifications.EQ_DATA).putExtra("eqData", eqData).apply {
|
||||
setPackage(packageName)
|
||||
})
|
||||
}
|
||||
|
||||
override fun onCustomEqReceived(customEq: CustomEq) {
|
||||
// TODO
|
||||
}
|
||||
|
||||
override fun onCapabilitiesReceived(capabilities: List<Capability>) {
|
||||
// TODO
|
||||
}
|
||||
|
||||
override fun onUnknownPacketReceived(packet: ByteArray) {
|
||||
Log.d(
|
||||
"AACPManager",
|
||||
@@ -1739,7 +1747,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
|
||||
|
||||
val socketFailureChannel = NotificationChannel(
|
||||
"socket_connection_failure",
|
||||
"AirPods Socket Connection Issues",
|
||||
"AirPods BluetoothConnectionManager.getAACPSocket()? Connection Issues",
|
||||
NotificationManager.IMPORTANCE_HIGH
|
||||
).apply {
|
||||
description = "Notifications about problems connecting to AirPods protocol"
|
||||
@@ -1785,7 +1793,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
|
||||
if (BuildConfig.FLAVOR != "xposed") {
|
||||
Log.w(
|
||||
TAG,
|
||||
"Not showing socket error notification to user, the service shouldn't be running if it isn't supported."
|
||||
"Not showing BluetoothConnectionManager.getAACPSocket()? error notification to user, the service shouldn't be running if it isn't supported."
|
||||
)
|
||||
return
|
||||
}
|
||||
@@ -1914,7 +1922,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
|
||||
)
|
||||
it.setViewVisibility(
|
||||
R.id.left_charging_icon,
|
||||
if (leftBattery?.status == BatteryStatus.CHARGING) View.VISIBLE else View.GONE
|
||||
if (leftBattery?.status == BatteryStatus.CHARGING || leftBattery?.status == BatteryStatus.OPTIMIZED_CHARGING) View.VISIBLE else View.GONE
|
||||
)
|
||||
|
||||
it.setTextViewText(R.id.right_battery_widget, rightBattery?.let {
|
||||
@@ -1925,7 +1933,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
|
||||
)
|
||||
it.setViewVisibility(
|
||||
R.id.right_charging_icon,
|
||||
if (rightBattery?.status == BatteryStatus.CHARGING) View.VISIBLE else View.GONE
|
||||
if (rightBattery?.status == BatteryStatus.CHARGING || rightBattery?.status == BatteryStatus.OPTIMIZED_CHARGING ) View.VISIBLE else View.GONE
|
||||
)
|
||||
|
||||
it.setTextViewText(R.id.case_battery_widget, caseBattery?.let {
|
||||
@@ -1936,7 +1944,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
|
||||
)
|
||||
it.setViewVisibility(
|
||||
R.id.case_charging_icon,
|
||||
if (caseBattery?.status == BatteryStatus.CHARGING) View.VISIBLE else View.GONE
|
||||
if (caseBattery?.status == BatteryStatus.CHARGING || caseBattery?.status == BatteryStatus.OPTIMIZED_CHARGING ) View.VISIBLE else View.GONE
|
||||
)
|
||||
|
||||
it.setViewVisibility(
|
||||
@@ -2040,10 +2048,10 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||
)
|
||||
|
||||
if (!::socket.isInitialized) {
|
||||
if (BluetoothConnectionManager.getAACPSocket() == null) {
|
||||
return
|
||||
}
|
||||
if (connected && (config.bleOnlyMode || socket.isConnected)) {
|
||||
if (BluetoothConnectionManager.getAACPSocket()?.isConnected == true) {
|
||||
val updatedNotificationBuilder =
|
||||
NotificationCompat.Builder(this, "airpods_connection_status")
|
||||
.setSmallIcon(R.drawable.airpods)
|
||||
@@ -2091,8 +2099,8 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
|
||||
notificationManager.cancel(1)
|
||||
} else if (!connected) {
|
||||
notificationManager.cancel(2)
|
||||
} else if (!config.bleOnlyMode && !socket.isConnected) {
|
||||
showSocketConnectionFailureNotification("Socket created, but not connected. Check logs")
|
||||
} else if (!config.bleOnlyMode && BluetoothConnectionManager.getAACPSocket()?.isConnected != true) {
|
||||
showSocketConnectionFailureNotification("BluetoothConnectionManager.getAACPSocket()? created, but not connected. Check logs")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2467,8 +2475,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
|
||||
Log.d(
|
||||
TAG, "owns connection: $ownsConnection"
|
||||
)
|
||||
if (!::socket.isInitialized) return
|
||||
if (socket.isConnected) {
|
||||
if (BluetoothConnectionManager.getAACPSocket()?.isConnected == true) {
|
||||
if (!XposedRemotePrefProvider.create().getBoolean("vendor_id_hook", false) || ownsConnection == 0) {
|
||||
Log.d(TAG, "not taking over, vendorid is probably not set to apple")
|
||||
return
|
||||
@@ -2677,10 +2684,11 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
|
||||
fun connectToSocket(
|
||||
adapter: BluetoothAdapter, device: BluetoothDevice, manual: Boolean = false
|
||||
) {
|
||||
if (BluetoothConnectionManager.getAACPSocket() != null && BluetoothConnectionManager.getAACPSocket()?.isConnected == true) return
|
||||
Log.d(TAG, "<LogCollector:Start> Connecting to socket")
|
||||
val uuid: ParcelUuid = ParcelUuid.fromString("74ec2172-0bad-4d01-8f77-997b2be0722a")
|
||||
// if (!isConnectedLocally) {
|
||||
socket = try {
|
||||
val socket = try {
|
||||
createBluetoothSocket(adapter, device, uuid, 4097)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to create BluetoothSocket: ${e.message}")
|
||||
@@ -2768,7 +2776,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
|
||||
}
|
||||
}
|
||||
if (!socket.isConnected) {
|
||||
Log.d(TAG, "<LogCollector:Complete:Failed> Socket not connected")
|
||||
Log.d(TAG, "<LogCollector:Complete:Failed> socket not connected")
|
||||
if (manual) {
|
||||
sendToast(
|
||||
"Couldn't connect to socket: timeout."
|
||||
@@ -2779,13 +2787,14 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
|
||||
return
|
||||
}
|
||||
this@AirPodsService.device = device
|
||||
socket.let {
|
||||
BluetoothConnectionManager.getAACPSocket()?.let {
|
||||
aacpManager.sendPacket(aacpManager.createHandshakePacket())
|
||||
aacpManager.sendSetFeatureFlagsPacket()
|
||||
aacpManager.sendNotificationRequest()
|
||||
Log.d(TAG, "Requesting proximity keys")
|
||||
aacpManager.sendRequestProximityKeys((AACPManager.Companion.ProximityKeyType.IRK.value + AACPManager.Companion.ProximityKeyType.ENC_KEY.value).toByte())
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
delay(200)
|
||||
aacpManager.sendPacket(aacpManager.createHandshakePacket())
|
||||
delay(200)
|
||||
aacpManager.sendSetFeatureFlagsPacket()
|
||||
@@ -2813,55 +2822,53 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
|
||||
setupStemActions()
|
||||
|
||||
while (socket.isConnected) {
|
||||
socket.let { it ->
|
||||
try {
|
||||
val buffer = ByteArray(1024)
|
||||
val bytesRead = it.inputStream.read(buffer)
|
||||
var data: ByteArray
|
||||
if (bytesRead > 0) {
|
||||
data = buffer.copyOfRange(0, bytesRead)
|
||||
sendBroadcast(Intent(AirPodsNotifications.AIRPODS_DATA).apply {
|
||||
putExtra("data", buffer.copyOfRange(0, bytesRead))
|
||||
setPackage(packageName)
|
||||
})
|
||||
val bytes = buffer.copyOfRange(0, bytesRead)
|
||||
val formattedHex = bytes.joinToString(" ") { "%02X".format(it) }
|
||||
try {
|
||||
val buffer = ByteArray(1024)
|
||||
val bytesRead = it.inputStream.read(buffer)
|
||||
var data: ByteArray
|
||||
if (bytesRead > 0) {
|
||||
data = buffer.copyOfRange(0, bytesRead)
|
||||
sendBroadcast(Intent(AirPodsNotifications.AIRPODS_DATA).apply {
|
||||
putExtra("data", buffer.copyOfRange(0, bytesRead))
|
||||
setPackage(packageName)
|
||||
})
|
||||
val bytes = buffer.copyOfRange(0, bytesRead)
|
||||
val formattedHex = bytes.joinToString(" ") { "%02X".format(it) }
|
||||
// CrossDevice.sendReceivedPacket(bytes)
|
||||
updateNotificationContent(
|
||||
true,
|
||||
sharedPreferences.getString("name", device.name),
|
||||
batteryNotification.getBattery()
|
||||
)
|
||||
updateNotificationContent(
|
||||
true,
|
||||
sharedPreferences.getString("name", device.name),
|
||||
batteryNotification.getBattery()
|
||||
)
|
||||
|
||||
aacpManager.receivePacket(data)
|
||||
aacpManager.receivePacket(data)
|
||||
|
||||
if (!isHeadTrackingData(data)) {
|
||||
Log.d("AirPodsData", "Data received: $formattedHex")
|
||||
logPacket(data, "AirPods")
|
||||
}
|
||||
|
||||
} else if (bytesRead == -1) {
|
||||
Log.d("AirPods Service", "Socket closed (bytesRead = -1)")
|
||||
sendBroadcast(Intent(AirPodsNotifications.AIRPODS_DISCONNECTED).apply {
|
||||
setPackage(packageName)
|
||||
})
|
||||
aacpManager.disconnected()
|
||||
return@launch
|
||||
if (!isHeadTrackingData(data)) {
|
||||
Log.d("AirPodsData", "Data received: $formattedHex")
|
||||
logPacket(data, "AirPods")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Error reading data, we have probably disconnected.")
|
||||
e.printStackTrace()
|
||||
|
||||
} else if (bytesRead == -1) {
|
||||
Log.d("AirPodsService", "socket closed (bytesRead = -1)")
|
||||
sendBroadcast(Intent(AirPodsNotifications.AIRPODS_DISCONNECTED).apply {
|
||||
setPackage(packageName)
|
||||
})
|
||||
aacpManager.disconnected()
|
||||
return@launch
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Error reading data, we have probably disconnected.")
|
||||
e.printStackTrace()
|
||||
sendBroadcast(Intent(AirPodsNotifications.AIRPODS_DISCONNECTED).apply {
|
||||
setPackage(packageName)
|
||||
})
|
||||
aacpManager.disconnected()
|
||||
return@launch
|
||||
}
|
||||
|
||||
}
|
||||
Log.d("AirPods Service", "Socket closed")
|
||||
Log.d("AirPods Service", "socket closed")
|
||||
// isConnectedLocally = false
|
||||
socket.close()
|
||||
aacpManager.disconnected()
|
||||
updateNotificationContent(false)
|
||||
sendBroadcast(Intent(AirPodsNotifications.AIRPODS_DISCONNECTED).apply {
|
||||
@@ -2871,20 +2878,19 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
Log.d(TAG, "Failed to connect to socket: ${e.message}")
|
||||
Log.d(TAG, "Failed to connect to BluetoothConnectionManager.getAACPSocket()?: ${e.message}")
|
||||
showSocketConnectionFailureNotification("Failed to establish connection: ${e.localizedMessage}")
|
||||
// isConnectedLocally = false
|
||||
this@AirPodsService.device = device
|
||||
updateNotificationContent(false)
|
||||
}
|
||||
// } else {
|
||||
// Log.d(TAG, "Already connected locally, skipping socket connection (isConnectedLocally = $isConnectedLocally, socket.isConnected = ${this::socket.isInitialized && socket.isConnected})")
|
||||
// Log.d(TAG, "Already connected locally, skipping BluetoothConnectionManager.getAACPSocket()? connection (isConnectedLocally = $isConnectedLocally, BluetoothConnectionManager.getAACPSocket()?.isConnected = ${this::BluetoothConnectionManager.getAACPSocket()?.isInitialized && BluetoothConnectionManager.getAACPSocket()?.isConnected})")
|
||||
// }
|
||||
}
|
||||
|
||||
fun disconnectForCD() {
|
||||
if (!this::socket.isInitialized) return
|
||||
socket.close()
|
||||
BluetoothConnectionManager.getAACPSocket()?.close()
|
||||
MediaController.pausedWhileTakingOver = false
|
||||
Log.d(TAG, "Disconnected from AirPods, showing island.")
|
||||
showIsland(
|
||||
@@ -2915,8 +2921,12 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
|
||||
}
|
||||
|
||||
fun disconnectAirPods() {
|
||||
if (!this::socket.isInitialized) return
|
||||
socket.close()
|
||||
if (BluetoothConnectionManager.getAACPSocket() == null) return
|
||||
try {
|
||||
BluetoothConnectionManager.getAACPSocket()?.close()
|
||||
} catch(e: Exception) {
|
||||
Log.e(TAG, "error closing aacp socket ${e.message}")
|
||||
}
|
||||
// isConnectedLocally = false
|
||||
aacpManager.disconnected()
|
||||
attManager.disconnected()
|
||||
@@ -3228,10 +3238,6 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun isConnected(): Boolean {
|
||||
return if (::socket.isInitialized) socket.isConnected else false
|
||||
}
|
||||
}
|
||||
|
||||
private fun Int.dpToPx(): Int {
|
||||
|
||||
@@ -22,22 +22,13 @@ import android.content.SharedPreferences
|
||||
import android.os.Build
|
||||
|
||||
fun isSupported(sharedPreferences: SharedPreferences): Boolean {
|
||||
if (Build.VERSION.SDK_INT == 37) return true
|
||||
val isBypassFlagActive = sharedPreferences.getBoolean("bypass_device_check.v2", false)
|
||||
if (isBypassFlagActive) return true
|
||||
val isPixel = Build.MANUFACTURER.lowercase() == "google"
|
||||
val isOppoFamily = Build.MANUFACTURER.lowercase() in listOf("oneplus", "oppo", "realme")
|
||||
val isBypassFlagActive = sharedPreferences.getBoolean("bypass_device_check.v2", false)
|
||||
|
||||
if (isBypassFlagActive) return true
|
||||
|
||||
if (isPixel) {
|
||||
when (Build.VERSION.SDK_INT) {
|
||||
36 -> {
|
||||
return Build.ID.startsWith("CP1A")
|
||||
}
|
||||
|
||||
37 -> {
|
||||
return true
|
||||
}
|
||||
}
|
||||
if (isPixel && Build.VERSION.SDK_INT == 36) {
|
||||
return Build.ID.startsWith("CP1A")
|
||||
} else if (isOppoFamily) {
|
||||
return Build.VERSION.SDK_INT >= 36
|
||||
}
|
||||
|
||||
@@ -277,4 +277,6 @@
|
||||
<string name="optimized_charging_description">AirPods can learn from your daily usage and determine when to charge to an optmized limit and when to allow or full charge. This limit adapts to your daily usage and preserves your battery lifespan over time.\nThis setting may not affect unsupported AirPods, or AirPods on an older firmware version.</string>
|
||||
<string name="enable_app_in_xposed_or_update_device">Enable LibrePods in Xposed or update your device to proceed.</string>
|
||||
<string name="play_foss_premium_banner">Due to an error in billing, premium access will expire in %1$d days. If you already upgraded the app, please click on this message to email billing@kavish.xyz to restore or verify access. Apologies for the inconvenience.</string>
|
||||
<string name="custom">Custom</string>
|
||||
<string name="recommended">Recommended</string>
|
||||
</resources>
|
||||
|
||||
Reference in New Issue
Block a user