A cheap attempt at a native Bluesky client for Android

LoginView: Allow selecting an Appview proxy

Add a dropdown menu to the login screen, allowing users to select an Appview proxy (e.g., Bluesky, Blacksky). This proxy setting is now required for login and is saved to preferences alongside the session data.

The selected proxy is passed to the Bluesky API client via the `atproto-proxy` header on all authenticated requests. The login, session storage, and client creation logic in the `Bluesky` data layer have been updated to handle this new `AppViewProxy` parameter.

+93 -11
+59 -4
app/src/main/java/industries/geesawra/monarch/LoginView.kt
··· 12 12 import androidx.compose.material.icons.filled.Visibility 13 13 import androidx.compose.material.icons.filled.VisibilityOff 14 14 import androidx.compose.material3.Button 15 + import androidx.compose.material3.DropdownMenuItem 16 + import androidx.compose.material3.ExperimentalMaterial3Api 17 + import androidx.compose.material3.ExposedDropdownMenuAnchorType 18 + import androidx.compose.material3.ExposedDropdownMenuBox 19 + import androidx.compose.material3.ExposedDropdownMenuDefaults 15 20 import androidx.compose.material3.Icon 16 21 import androidx.compose.material3.IconButton 17 22 import androidx.compose.material3.MaterialTheme ··· 40 45 import kotlinx.coroutines.launch 41 46 import sh.christian.ozone.api.Handle 42 47 48 + @OptIn(ExperimentalMaterial3Api::class) 43 49 @Composable 44 50 fun LoginView( 45 51 modifier: Modifier = Modifier, ··· 135 141 .onFocusChanged { focusState -> isPasswordFocused = focusState.isFocused } 136 142 ) 137 143 144 + 145 + val proxies = listOf( 146 + "Bluesky Appview" to "did:web:api.bsky.app#bsky_appview", 147 + "Blacksky Appview" to "did:web:api.blacksky.community#bsky_appview" 148 + ) 149 + 150 + val expanded = remember { mutableStateOf(false) } 151 + val selectedProxyPretty = remember { mutableStateOf(proxies.first().first) } 152 + val selectedProxy = remember { mutableStateOf(proxies.first().second) } 153 + 154 + 155 + ExposedDropdownMenuBox( 156 + modifier = Modifier 157 + .fillMaxWidth() 158 + .padding(top = 8.dp), 159 + expanded = expanded.value, 160 + onExpandedChange = { 161 + expanded.value = !expanded.value 162 + } 163 + ) { 164 + TextField( 165 + value = selectedProxyPretty.value, 166 + onValueChange = {}, 167 + readOnly = true, 168 + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded.value) }, 169 + modifier = Modifier 170 + .menuAnchor(ExposedDropdownMenuAnchorType.PrimaryEditable) 171 + .fillMaxWidth() 172 + ) 173 + 174 + ExposedDropdownMenu( 175 + modifier = Modifier 176 + .fillMaxWidth(), 177 + expanded = expanded.value, 178 + onDismissRequest = { expanded.value = false } 179 + ) { 180 + proxies.forEach { item -> 181 + DropdownMenuItem( 182 + text = { Text(text = item.first) }, 183 + onClick = { 184 + selectedProxyPretty.value = item.first 185 + selectedProxy.value = item.second 186 + expanded.value = false 187 + } 188 + ) 189 + } 190 + } 191 + } 192 + 138 193 Button( 139 194 onClick = { 140 195 scope.launch { 141 196 loggingIn.value = true 142 - bc.login(currentPDS, handle, password).onSuccess { 197 + bc.login(currentPDS, handle, password, selectedProxy.value).onSuccess { 143 198 navigate() 144 199 }.onFailure { 145 200 Log.e("LoginView", "Login failed", it) ··· 157 212 158 213 var pdsString = "I'll look up your PDS automatically :^)" 159 214 if (currentPDS != "") { 160 - if (currentPDS.endsWith("bsky.network")) { 161 - pdsString = "Your PDS: bsky.app" 215 + pdsString = if (currentPDS.endsWith("bsky.network")) { 216 + "Your PDS: bsky.app" 162 217 } else { 163 - pdsString = "Your PDS: $currentPDS" 218 + "Your PDS: $currentPDS" 164 219 } 165 220 } 166 221 if (lookingUpPDS) {
+34 -7
app/src/main/java/industries/geesawra/monarch/datalayer/Bluesky.kt
··· 109 109 enum class AuthData { 110 110 PDSHost, 111 111 SessionData, 112 + AppViewProxy, 112 113 } 113 114 114 115 class LoginException(message: String?) : Exception(message) ··· 162 163 private val Context.dataStore by preferencesDataStore("bluesky") 163 164 private val SESSION = stringPreferencesKey(AuthData.SessionData.name) 164 165 private val PDSHOST = stringPreferencesKey(AuthData.PDSHost.name) 166 + private val APPVIEW_PROXY = stringPreferencesKey(AuthData.AppViewProxy.name) 165 167 166 168 suspend fun pdsForHandle(handle: String): Result<String> { 167 169 return runCatching { ··· 235 237 var createMutex: Mutex = Mutex() 236 238 var pdsURL: String? = null 237 239 238 - suspend fun storeSessionData(pdsURL: String, session: SessionData) { 240 + suspend fun storeSessionData(pdsURL: String, appviewProxy: String, session: SessionData) { 239 241 context.dataStore.edit { settings -> 240 242 settings[SESSION] = session.encodeToJson() 241 243 settings[PDSHOST] = pdsURL 244 + settings[APPVIEW_PROXY] = appviewProxy 242 245 } 243 246 } 244 247 ··· 246 249 context.dataStore.edit { settings -> 247 250 settings.remove(SESSION) 248 251 settings.remove(PDSHOST) 252 + settings.remove(APPVIEW_PROXY) 249 253 } 250 254 } 251 255 ··· 256 260 val sessionDataStringFlow: Flow<String> = context.dataStore.data.map { settings -> 257 261 settings[SESSION] ?: "" 258 262 } 263 + val appviewProxyStringFlow: Flow<String> = context.dataStore.data.map { settings -> 264 + settings[APPVIEW_PROXY] ?: "" 265 + } 259 266 260 267 val pdsURL = pdsURLFlow.first() 261 268 val sessionDataString = sessionDataStringFlow.first() 269 + val appviewProxy = appviewProxyStringFlow.first() 270 + 262 271 263 - return !(pdsURL.isEmpty() || sessionDataString.isEmpty()) 272 + return !(pdsURL.isEmpty() || sessionDataString.isEmpty() || appviewProxy.isEmpty()) 264 273 } 265 274 266 - suspend fun login(pdsURL: String, handle: String, password: String): Result<Unit> { 275 + suspend fun login( 276 + pdsURL: String, 277 + handle: String, 278 + password: String, 279 + appviewProxy: String 280 + ): Result<Unit> { 267 281 createMutex.lock() 268 282 val httpClient = HttpClient(OkHttp) { 269 283 defaultRequest { ··· 296 310 is AtpResponse.Success<CreateSessionResponse> -> s.response 297 311 } 298 312 299 - storeSessionData(pdsURL, SessionData.fromCreateSessionResponse(sessionResponse)) 313 + storeSessionData( 314 + pdsURL, 315 + appviewProxy, 316 + SessionData.fromCreateSessionResponse(sessionResponse) 317 + ) 300 318 session = null 301 319 this.client = null 302 320 ··· 312 330 313 331 private fun mkClient( 314 332 pds: String, 333 + appviewProxy: String, 315 334 sessionData: SessionData, 316 335 labelers: List<String> = listOf() 317 336 ): AuthenticatedXrpcBlueskyApi { ··· 319 338 defaultRequest { 320 339 url(pds) 321 340 headers["atproto-accept-labelers"] = labelers.joinToString() 341 + headers["atproto-proxy"] = appviewProxy 322 342 } 323 343 install(HttpTimeout) { 324 344 requestTimeoutMillis = 15000 ··· 335 355 336 356 private suspend fun refreshIfNeeded( 337 357 pdsURL: String, 358 + appviewProxy: String, 338 359 token: SessionData, 339 360 ): Result<Unit> { 340 361 return runCatching { ··· 397 418 ) 398 419 399 420 this.session = SessionData.fromRefreshSessionResponse(rs) 400 - storeSessionData(pdsURL, this.session!!) 421 + storeSessionData(pdsURL, appviewProxy, this.session!!) 401 422 return Result.success(Unit) 402 423 } 403 424 ··· 432 453 val sessionDataStringFlow: Flow<String> = context.dataStore.data.map { settings -> 433 454 settings[SESSION] ?: "" 434 455 } 456 + val appviewProxyFlow: Flow<String> = context.dataStore.data.map { settings -> 457 + settings[APPVIEW_PROXY] ?: "" 458 + } 435 459 436 460 val pdsURL = pdsURLFlow.first() 437 461 val sessionDataString = sessionDataStringFlow.first() 462 + val appviewProxy = appviewProxyFlow.first() 438 463 439 - if (pdsURL.isEmpty() || sessionDataString.isEmpty()) { 464 + if (pdsURL.isEmpty() || sessionDataString.isEmpty() || appviewProxy.isEmpty()) { 440 465 createMutex.unlock() 441 466 return Result.failure(Exception("No session data found")) 442 467 } 443 468 444 469 val sessionData = SessionData.decodeFromJson(sessionDataString) 445 470 446 - refreshIfNeeded(pdsURL, sessionData).onFailure { 471 + refreshIfNeeded(pdsURL, appviewProxy, sessionData).onFailure { 447 472 createMutex.unlock() 448 473 return Result.failure(it) 449 474 } ··· 451 476 452 477 this.client = mkClient( 453 478 pdsURL, 479 + appviewProxy, 454 480 sessionData, 455 481 ) 456 482 457 483 val labelers = this.subscribedLabelers().getOrThrow().keys.mapNotNull { it?.did } 458 484 this.client = mkClient( 459 485 pdsURL, 486 + appviewProxy, 460 487 sessionData, 461 488 labelers 462 489 )