Skip to content

Commit a523960

Browse files
authored
Merge pull request #48 from sameerasw/develop
Develop
2 parents 5d3b412 + 77147a4 commit a523960

21 files changed

Lines changed: 879 additions & 126 deletions

app/build.gradle.kts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,8 @@ android {
1515
applicationId = "com.sameerasw.airsync"
1616
minSdk = 30
1717
targetSdk = 36
18-
versionCode = 9
19-
versionName = "2.1.2"
18+
versionCode = 11
19+
versionName = "2.1.4"
2020

2121
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
2222
}
@@ -53,6 +53,9 @@ dependencies {
5353
implementation(libs.androidx.ui.tooling.preview)
5454
implementation(libs.androidx.material3)
5555

56+
// Smartspacer SDK
57+
implementation("com.kieronquinn.smartspacer:sdk-plugin:1.1")
58+
5659
// Material Components (XML themes: Theme.Material3.*)
5760
implementation("com.google.android.material:material:1.12.0")
5861

app/src/main/AndroidManifest.xml

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,27 @@
138138
<action android:name="com.sameerasw.airsync.CONTINUE_BROWSING_DISMISS" />
139139
</intent-filter>
140140
</receiver>
141+
142+
<!-- Smartspacer Target Provider -->
143+
<provider
144+
android:name=".smartspacer.AirSyncDeviceTarget"
145+
android:authorities="${applicationId}.target.airsync_device"
146+
android:permission="com.kieronquinn.app.smartspacer.permission.ACCESS_SMARTSPACER_TARGETS"
147+
android:exported="true">
148+
<intent-filter>
149+
<action android:name="com.kieronquinn.app.smartspacer.TARGET" />
150+
</intent-filter>
151+
</provider>
152+
153+
<!-- Smartspacer Target Update Receiver -->
154+
<receiver
155+
android:name=".smartspacer.AirSyncTargetUpdateReceiver"
156+
android:exported="true"
157+
android:permission="com.kieronquinn.app.smartspacer.permission.SEND_UPDATE_BROADCAST">
158+
<intent-filter>
159+
<action android:name="com.kieronquinn.app.smartspacer.REQUEST_TARGET_UPDATE" />
160+
</intent-filter>
161+
</receiver>
141162
</application>
142163

143164
<queries>

app/src/main/java/com/sameerasw/airsync/data/local/DataStoreManager.kt

Lines changed: 166 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,9 @@ import com.sameerasw.airsync.domain.model.NetworkDeviceConnection
1414
import com.sameerasw.airsync.domain.model.NotificationApp
1515
import kotlinx.coroutines.flow.Flow
1616
import kotlinx.coroutines.flow.map
17+
import kotlinx.coroutines.runBlocking
18+
import kotlinx.coroutines.flow.first
19+
import org.json.JSONObject
1720

1821
val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "airsync_settings")
1922

@@ -54,7 +57,9 @@ class DataStoreManager(private val context: Context) {
5457
private val SEND_NOW_PLAYING_ENABLED = booleanPreferencesKey("send_now_playing_enabled")
5558
// New: Keep previous link toggle
5659
private val KEEP_PREVIOUS_LINK_ENABLED = booleanPreferencesKey("keep_previous_link_enabled")
57-
private val TAILSCALE_SUPPORT_ENABLED = booleanPreferencesKey("tailscale_support_enabled")
60+
// New: Always show in Smartspacer toggle
61+
private val SMARTSPACER_SHOW_WHEN_DISCONNECTED = booleanPreferencesKey("smartspacer_show_when_disconnected")
62+
private val EXPAND_NETWORKING_ENABLED = booleanPreferencesKey("expand_networking_enabled")
5863

5964
// Network-aware device connections
6065
private val NETWORK_DEVICES_PREFIX = "network_device_"
@@ -197,15 +202,28 @@ class DataStoreManager(private val context: Context) {
197202
}
198203
}
199204

200-
suspend fun setTailscaleSupportEnabled(enabled: Boolean) {
205+
// New: Always show in Smartspacer toggle
206+
suspend fun setSmartspacerShowWhenDisconnected(enabled: Boolean) {
207+
context.dataStore.edit { preferences ->
208+
preferences[SMARTSPACER_SHOW_WHEN_DISCONNECTED] = enabled
209+
}
210+
}
211+
212+
fun getSmartspacerShowWhenDisconnected(): Flow<Boolean> {
213+
return context.dataStore.data.map { preferences ->
214+
preferences[SMARTSPACER_SHOW_WHEN_DISCONNECTED] ?: false
215+
}
216+
}
217+
218+
suspend fun setExpandNetworkingEnabled(enabled: Boolean) {
201219
context.dataStore.edit { prefs ->
202-
prefs[TAILSCALE_SUPPORT_ENABLED] = enabled
220+
prefs[EXPAND_NETWORKING_ENABLED] = enabled
203221
}
204222
}
205223

206-
fun getTailscaleSupportEnabled(): Flow<Boolean> {
224+
fun getExpandNetworkingEnabled(): Flow<Boolean> {
207225
return context.dataStore.data.map { prefs ->
208-
prefs[TAILSCALE_SUPPORT_ENABLED] ?: false
226+
prefs[EXPAND_NETWORKING_ENABLED] ?: false
209227
}
210228
}
211229

@@ -543,4 +561,146 @@ class DataStoreManager(private val context: Context) {
543561
preferences[stringPreferencesKey("${NETWORK_DEVICES_PREFIX}${deviceName}_last_connected")] = timestamp.toString()
544562
}
545563
}
546-
}
564+
565+
/**
566+
* Export all DataStore preferences to a JSON string.
567+
* Excludes very large strings and anything that looks like a base64 image (data:image/...base64,...)
568+
*/
569+
suspend fun exportAllDataToJson(): String {
570+
val prefs = context.dataStore.data.first()
571+
val exportObj = JSONObject()
572+
val dataObj = JSONObject()
573+
val mutedArray = org.json.JSONArray()
574+
575+
prefs.asMap().forEach { (key, value) ->
576+
try {
577+
// Skip nulls
578+
if (value == null) return@forEach
579+
580+
// If string looks like an embedded image or is very large, skip
581+
if (value is String) {
582+
val lower = value.lowercase()
583+
if (lower.contains("data:image") || lower.contains("base64,") || value.length > 10000) {
584+
// mark skipped
585+
return@forEach
586+
}
587+
}
588+
589+
// Collect muted apps: keys like app_<package>_enabled with value false
590+
if (key.name.startsWith("app_") && key.name.endsWith("_enabled")) {
591+
val pkg = key.name.removePrefix("app_").removeSuffix("_enabled")
592+
val enabled = (value as? Boolean) ?: true
593+
if (!enabled) {
594+
mutedArray.put(pkg)
595+
}
596+
}
597+
598+
val entry = JSONObject()
599+
when (value) {
600+
is String -> {
601+
entry.put("type", "string")
602+
entry.put("value", value)
603+
}
604+
is Boolean -> {
605+
entry.put("type", "boolean")
606+
entry.put("value", value)
607+
}
608+
is Int -> {
609+
entry.put("type", "int")
610+
entry.put("value", value)
611+
}
612+
is Long -> {
613+
entry.put("type", "long")
614+
entry.put("value", value)
615+
}
616+
else -> {
617+
// Fallback to string representation
618+
entry.put("type", "string")
619+
entry.put("value", value.toString())
620+
}
621+
}
622+
dataObj.put(key.name, entry)
623+
} catch (_: Exception) {
624+
// ignore problematic entries
625+
}
626+
}
627+
628+
exportObj.put("version", 1)
629+
exportObj.put("preferences", dataObj)
630+
// Include list of apps explicitly disabled for notification syncing so import can restore this
631+
exportObj.put("muted_apps", mutedArray)
632+
return exportObj.toString()
633+
}
634+
635+
/**
636+
* Import preferences from JSON produced by exportAllDataToJson.
637+
* Only writes keys present in the JSON; missing keys are left unchanged.
638+
*/
639+
suspend fun importAllDataFromJson(json: String): Boolean {
640+
try {
641+
val root = JSONObject(json)
642+
val prefsObj = root.optJSONObject("preferences") ?: return false
643+
val mutedApps = mutableListOf<String>()
644+
val mutedJson = root.optJSONArray("muted_apps")
645+
if (mutedJson != null) {
646+
for (i in 0 until mutedJson.length()) {
647+
try { mutedApps.add(mutedJson.getString(i)) } catch (_: Exception) {}
648+
}
649+
}
650+
651+
context.dataStore.edit { preferences ->
652+
val keys = prefsObj.keys()
653+
while (keys.hasNext()) {
654+
val keyName = keys.next()
655+
try {
656+
val entry = prefsObj.getJSONObject(keyName)
657+
val type = entry.optString("type", "string")
658+
when (type) {
659+
"boolean" -> {
660+
val key = booleanPreferencesKey(keyName)
661+
val v = entry.getBoolean("value")
662+
preferences[key] = v
663+
}
664+
"int" -> {
665+
val key = intPreferencesKey(keyName)
666+
val v = entry.getInt("value")
667+
preferences[key] = v
668+
}
669+
"long" -> {
670+
val key = longPreferencesKey(keyName)
671+
val v = entry.getLong("value")
672+
preferences[key] = v
673+
}
674+
else -> {
675+
val key = stringPreferencesKey(keyName)
676+
val v = entry.optString("value", "")
677+
// Skip if value looks like embedded image or very large
678+
val lower = v.lowercase()
679+
if (lower.contains("data:image") || lower.contains("base64,") || v.length > 10000) {
680+
// skip this key
681+
} else {
682+
preferences[key] = v
683+
}
684+
}
685+
}
686+
} catch (_: Exception) {
687+
// ignore specific key import errors
688+
}
689+
}
690+
691+
// Apply muted apps (explicitly disable per-app notification flags)
692+
mutedApps.forEach { pkg ->
693+
try {
694+
val enabledKey = booleanPreferencesKey("app_${pkg}_enabled")
695+
preferences[enabledKey] = false
696+
} catch (_: Exception) {}
697+
}
698+
}
699+
700+
return true
701+
} catch (e: Exception) {
702+
e.printStackTrace()
703+
return false
704+
}
705+
}
706+
}

app/src/main/java/com/sameerasw/airsync/data/repository/AirSyncRepositoryImpl.kt

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,14 @@ class AirSyncRepositoryImpl(
150150
return dataStoreManager.getKeepPreviousLinkEnabled()
151151
}
152152

153+
override suspend fun setSmartspacerShowWhenDisconnected(enabled: Boolean) {
154+
dataStoreManager.setSmartspacerShowWhenDisconnected(enabled)
155+
}
156+
157+
override fun getSmartspacerShowWhenDisconnected(): Flow<Boolean> {
158+
return dataStoreManager.getSmartspacerShowWhenDisconnected()
159+
}
160+
153161
override suspend fun setUserManuallyDisconnected(disconnected: Boolean) {
154162
dataStoreManager.setUserManuallyDisconnected(disconnected)
155163
}

app/src/main/java/com/sameerasw/airsync/domain/model/UiState.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ data class UiState(
2626
val isContinueBrowsingEnabled: Boolean = true,
2727
val isSendNowPlayingEnabled: Boolean = true,
2828
val isKeepPreviousLinkEnabled: Boolean = true,
29+
val isSmartspacerShowWhenDisconnected: Boolean = false,
2930
// Mac device status
3031
val macDeviceStatus: MacDeviceStatus? = null,
3132
// Auth failure dialog

app/src/main/java/com/sameerasw/airsync/domain/repository/AirSyncRepository.kt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,10 @@ interface AirSyncRepository {
6464
suspend fun setKeepPreviousLinkEnabled(enabled: Boolean)
6565
fun getKeepPreviousLinkEnabled(): Flow<Boolean>
6666

67+
// Smartspacer settings
68+
suspend fun setSmartspacerShowWhenDisconnected(enabled: Boolean)
69+
fun getSmartspacerShowWhenDisconnected(): Flow<Boolean>
70+
6771
// User manual disconnect tracking
6872
suspend fun setUserManuallyDisconnected(disconnected: Boolean)
6973
fun getUserManuallyDisconnected(): Flow<Boolean>

app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/ConnectionStatusCard.kt

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,11 +30,13 @@ import androidx.compose.ui.graphics.Color
3030
import androidx.compose.ui.layout.ContentScale
3131
import androidx.compose.ui.res.painterResource
3232
import androidx.compose.ui.unit.dp
33+
import androidx.compose.ui.platform.LocalHapticFeedback
3334
import com.sameerasw.airsync.domain.model.ConnectedDevice
3435
import com.sameerasw.airsync.domain.model.UiState
3536
import com.sameerasw.airsync.ui.theme.ExtraCornerRadius
3637
import com.sameerasw.airsync.ui.theme.minCornerRadius
3738
import com.sameerasw.airsync.utils.DevicePreviewResolver
39+
import com.sameerasw.airsync.utils.HapticUtil
3840

3941
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
4042
@Composable
@@ -46,6 +48,8 @@ fun ConnectionStatusCard(
4648
lastConnected: Boolean,
4749
uiState: UiState,
4850
) {
51+
val haptics = androidx.compose.ui.platform.LocalHapticFeedback.current
52+
4953
val cardShape = if (!isConnected) {
5054
RoundedCornerShape(
5155
topStart = ExtraCornerRadius,
@@ -172,7 +176,10 @@ fun ConnectionStatusCard(
172176
)
173177

174178
if (isConnected) {
175-
OutlinedButton(onClick = onDisconnect) {
179+
OutlinedButton(onClick = {
180+
HapticUtil.performClick(haptics)
181+
onDisconnect()
182+
}) {
176183
Text("Disconnect")
177184
}
178185
}

0 commit comments

Comments
 (0)