A cheap attempt at a native Bluesky client for Android
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Bluesky: maybe fix this freaking reauth bug?

geesawra 60b316af a2684072

+149 -67
+1
.idea/dictionaries/project.xml
··· 3 3 <words> 4 4 <w>bsky</w> 5 5 <w>skeets</w> 6 + <w>xrpc</w> 6 7 </words> 7 8 </dictionary> 8 9 </component>
+4 -5
app/src/main/AndroidManifest.xml
··· 1 1 <?xml version="1.0" encoding="utf-8"?> 2 - <manifest xmlns:android="http://schemas.android.com/apk/res/android" 3 - xmlns:tools="http://schemas.android.com/tools"> 2 + <manifest xmlns:android="http://schemas.android.com/apk/res/android"> 3 + 4 4 <uses-permission android:name="android.permission.INTERNET" /> 5 5 <application 6 + android:name=".Application" 6 7 android:allowBackup="true" 7 8 android:dataExtractionRules="@xml/data_extraction_rules" 8 9 android:fullBackupContent="@xml/backup_rules" ··· 10 11 android:label="@string/app_name" 11 12 android:roundIcon="@mipmap/ic_launcher_round" 12 13 android:supportsRtl="true" 13 - android:theme="@style/Theme.JerryNo" 14 - android:name=".Application"> 14 + android:theme="@style/Theme.JerryNo"> 15 15 <activity 16 16 android:name=".MainActivity" 17 17 android:exported="true" ··· 19 19 android:windowSoftInputMode="adjustResize"> 20 20 <intent-filter> 21 21 <action android:name="android.intent.action.MAIN" /> 22 - 23 22 <category android:name="android.intent.category.LAUNCHER" /> 24 23 </intent-filter> 25 24 </activity>
+10 -3
app/src/main/java/industries/geesawra/jerryno/MainActivity.kt
··· 11 11 import androidx.compose.foundation.layout.Box 12 12 import androidx.compose.foundation.layout.fillMaxSize 13 13 import androidx.compose.foundation.layout.padding 14 + import androidx.compose.foundation.rememberScrollState 15 + import androidx.compose.foundation.verticalScroll 14 16 import androidx.compose.material3.ExperimentalMaterial3Api 15 17 import androidx.compose.material3.MaterialTheme 16 18 import androidx.compose.material3.Surface ··· 63 65 super.onCreate(savedInstanceState) 64 66 enableEdgeToEdge() 65 67 68 + 66 69 setContent { 67 70 JerryNoTheme { 68 71 val context = LocalContext.current ··· 95 98 modifier = Modifier.fillMaxSize(), 96 99 color = MaterialTheme.colorScheme.background 97 100 ) { 98 - 99 101 timelineViewModel.loadSession() 100 102 if (!timelineViewModel.uiState.sessionChecked) { 101 103 Box( ··· 127 129 composable(route = TimelineScreen.Timeline.name) { 128 130 TimelineView( 129 131 timelineViewModel = timelineViewModel, 130 - coroutineScope = rememberCoroutineScope() 132 + coroutineScope = rememberCoroutineScope(), 133 + loginError = { 134 + navController.navigate(TimelineScreen.Login.name) 135 + } 131 136 ) 132 137 } 133 138 composable(route = TimelineScreen.Compose.name) { ··· 154 159 color = MaterialTheme.colorScheme.background // Set login screen background 155 160 ) { 156 161 Box( 157 - modifier = Modifier.fillMaxSize(), 162 + modifier = Modifier 163 + .fillMaxSize() 164 + .verticalScroll(rememberScrollState()), 158 165 contentAlignment = Alignment.Center 159 166 ) { 160 167 LoginView {
+15 -10
app/src/main/java/industries/geesawra/jerryno/TimelineView.kt
··· 27 27 import androidx.compose.foundation.text.KeyboardActions 28 28 import androidx.compose.material.icons.Icons 29 29 import androidx.compose.material.icons.automirrored.filled.Send 30 - import androidx.compose.material.icons.filled.Attachment 30 + import androidx.compose.material.icons.filled.CameraRoll 31 31 import androidx.compose.material.icons.filled.Create 32 32 import androidx.compose.material.icons.filled.Home 33 33 import androidx.compose.material.icons.filled.Notifications ··· 102 102 @Composable 103 103 fun TimelineView( 104 104 timelineViewModel: TimelineViewModel, 105 - coroutineScope: CoroutineScope 105 + coroutineScope: CoroutineScope, 106 + loginError: () -> Unit, 106 107 ) { 107 108 val scaffoldState = rememberBottomSheetScaffoldState( 108 109 bottomSheetState = rememberModalBottomSheetState( ··· 264 265 pickMedia.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageAndVideo)) 265 266 } 266 267 ) { 267 - Icon(Icons.Default.Attachment, contentDescription = "Attach media") 268 + Icon(Icons.Default.CameraRoll, contentDescription = "Attach media") 268 269 } 269 270 270 271 if (uploadingPost.value) { ··· 315 316 modifier = Modifier.padding(paddingValues), 316 317 coroutineScope = coroutineScope, 317 318 timelineViewModel = timelineViewModel, 318 - ) { 319 - coroutineScope.launch { 320 - scaffoldState.bottomSheetState.expand() 321 - } 322 - } 319 + fobOnClick = { 320 + coroutineScope.launch { 321 + scaffoldState.bottomSheetState.expand() 322 + } 323 + }, 324 + loginError = loginError 325 + ) 323 326 } 324 327 ) 325 328 } ··· 330 333 modifier: Modifier = Modifier, 331 334 coroutineScope: CoroutineScope, 332 335 timelineViewModel: TimelineViewModel, 333 - fobOnClick: () -> Unit // Changed to fobOnClick to avoid confusion with FAB acronym 336 + fobOnClick: () -> Unit, 337 + loginError: () -> Unit, 334 338 ) { 335 339 var currentDestination by rememberSaveable { mutableStateOf(TabBarDestinations.HOME) } 336 340 val scrollBehavior = TopAppBarDefaults.enterAlwaysScrollBehavior( ··· 349 353 350 354 LaunchedEffect(timelineViewModel.uiState.error) { 351 355 timelineViewModel.uiState.error?.let { 352 - Toast.makeText(ctx, "Error: ${it}", Toast.LENGTH_LONG) 356 + Toast.makeText(ctx, "Error: $it", Toast.LENGTH_LONG) 353 357 .show() 358 + loginError() 354 359 } 355 360 } 356 361
+119 -49
app/src/main/java/industries/geesawra/jerryno/datalayer/Bluesky.kt
··· 32 32 import io.ktor.client.plugins.HttpTimeout 33 33 import io.ktor.client.plugins.defaultRequest 34 34 import io.ktor.client.request.get 35 + import io.ktor.client.request.post 35 36 import io.ktor.http.HttpStatusCode 36 37 import io.ktor.http.URLProtocol 37 38 import io.ktor.http.path ··· 192 193 } 193 194 } 194 195 196 + suspend fun cleanSessionData() { 197 + context.dataStore.edit { settings -> 198 + settings.remove(SESSION) 199 + settings.remove(PDSHOST) 200 + } 201 + } 202 + 195 203 suspend fun hasSession(): Boolean { 196 204 val pdsURLFlow: Flow<String> = context.dataStore.data.map { settings -> 197 205 settings[PDSHOST] ?: "" ··· 207 215 } 208 216 209 217 suspend fun login(pdsURL: String, handle: String, password: String): Result<Unit> { 218 + createMutex.lock() 210 219 val httpClient = HttpClient(OkHttp) { 211 220 defaultRequest { 212 221 url(pdsURL) ··· 222 231 223 232 val s = client.createSession(CreateSessionRequest(handle, password)) 224 233 val sessionResponse: CreateSessionResponse = when (s) { 225 - is AtpResponse.Failure<*> -> return Result.failure( 226 - Exception( 227 - "Failed to create session: ${ 228 - s.error?.message?.toLowerCase( 229 - Locale.current 230 - ) 231 - }" 234 + is AtpResponse.Failure<*> -> { 235 + createMutex.unlock() 236 + return Result.failure( 237 + Exception( 238 + "Failed to create session: ${ 239 + s.error?.message?.toLowerCase( 240 + Locale.current 241 + ) 242 + }" 243 + ) 232 244 ) 233 - ) 245 + } 234 246 235 247 is AtpResponse.Success<CreateSessionResponse> -> s.response 236 248 } 237 249 238 250 storeSessionData(pdsURL, SessionData.fromCreateSessionResponse(sessionResponse)) 251 + session = null 252 + this.client = null 239 253 254 + createMutex.unlock() 240 255 return Result.success(Unit) 241 256 } 242 257 258 + @Serializable 259 + private data class atpError( 260 + val error: String?, 261 + val message: String?, 262 + ) 263 + 264 + private suspend fun refreshIfNeeded(pdsURL: String, token: SessionData): Result<Unit> { 265 + return runCatching { 266 + val httpClient = HttpClient(OkHttp) { 267 + defaultRequest { 268 + url(pdsURL) 269 + } 270 + install(HttpTimeout) { 271 + requestTimeoutMillis = 15000 272 + connectTimeoutMillis = 15000 273 + socketTimeoutMillis = 15000 274 + } 275 + } 276 + 277 + val gs = httpClient.get { 278 + headers["Authorization"] = "Bearer " + token.accessJwt 279 + url { 280 + protocol = URLProtocol.HTTPS 281 + path("/xrpc/com.atproto.server.getSession") 282 + } 283 + } 284 + 285 + when (gs.status) { 286 + 287 + HttpStatusCode.OK -> run { 288 + this.session = token 289 + val tokens = 290 + BlueskyAuthPlugin.Tokens(token.accessJwt, token.refreshJwt) 291 + this.client = AuthenticatedXrpcBlueskyApi(httpClient, tokens) 292 + return Result.success(Unit) 293 + } 294 + 295 + else -> run { 296 + val body: String = gs.body() 297 + 298 + val error: atpError = 299 + BlueskyJson.decodeFromString( 300 + atpError.serializer(), 301 + body 302 + ) 303 + if (error.error == "ExpiredToken") { 304 + return@run 305 + } 306 + cleanSessionData() 307 + return Result.failure(Exception("Session checking failed, status code ${gs.status}: ${error.message}")) 308 + } 309 + } 310 + 311 + val rs = httpClient.post { 312 + headers["Authorization"] = "Bearer " + token.refreshJwt 313 + url { 314 + protocol = URLProtocol.HTTPS 315 + path("/xrpc/com.atproto.server.refreshSession") 316 + } 317 + } 318 + 319 + when (rs.status) { 320 + 321 + HttpStatusCode.OK -> run { 322 + val body: String = gs.body() 323 + val rs: RefreshSessionResponse = 324 + BlueskyJson.decodeFromString( 325 + RefreshSessionResponse.serializer(), 326 + body 327 + ) 328 + 329 + this.session = SessionData.fromRefreshSessionResponse(rs) 330 + storeSessionData(pdsURL, this.session!!) 331 + return Result.success(Unit) 332 + } 333 + 334 + else -> run { 335 + val body: String = rs.body() 336 + 337 + val error: atpError = 338 + BlueskyJson.decodeFromString( 339 + atpError.serializer(), 340 + body 341 + ) 342 + cleanSessionData() 343 + return Result.failure(Exception("Login refresh failed, status code ${rs.status}: ${error.message}")) 344 + } 345 + } 346 + 347 + } 348 + } 349 + 243 350 suspend fun create(): Result<Unit> { 244 351 return runCatching { 245 352 createMutex.lock() ··· 266 373 267 374 val sessionData = SessionData.decodeFromJson(sessionDataString) 268 375 269 - val httpClient = HttpClient(OkHttp) { 270 - defaultRequest { 271 - url(pdsURL) 272 - } 273 - 274 - install(HttpTimeout) { 275 - requestTimeoutMillis = 30000 276 - connectTimeoutMillis = 30000 277 - socketTimeoutMillis = 30000 278 - } 279 - } 280 - 281 - val tokens = 282 - BlueskyAuthPlugin.Tokens(sessionData.accessJwt, sessionData.refreshJwt) 283 - val authClient = AuthenticatedXrpcBlueskyApi(httpClient, tokens) 284 - 285 - val gs = authClient.getSession() 286 - when (gs) { 287 - is AtpResponse.Failure<*> -> run { 288 - return@run 289 - } 290 - 291 - is AtpResponse.Success<GetSessionResponse> -> { 292 - this.client = authClient 293 - this.session = SessionData.fromGetSessionResponse(gs.response) 294 - createMutex.unlock() 295 - return Result.success(Unit) 296 - } 376 + refreshIfNeeded(pdsURL, sessionData).onFailure { 377 + createMutex.unlock() 378 + return Result.failure(it) 297 379 } 298 380 299 - // No session, try to refresh 300 - val rs = authClient.refreshSession() 301 - Log.d("ASD", rs.toString()) 302 - when (rs) { 303 - is AtpResponse.Failure<*> -> return Result.failure(Exception("Could not refresh session, maybe login again?")) 304 - is AtpResponse.Success<RefreshSessionResponse> -> { 305 - this.client = authClient 306 - this.session = SessionData.fromRefreshSessionResponse(rs.response) 307 - storeSessionData(pdsURL, this.session!!) 308 - createMutex.unlock() 309 - return Result.success(Unit) 310 - } 311 - } 381 + createMutex.unlock() 312 382 } 313 383 } 314 384