A cheap attempt at a native Bluesky client for Android

*: kinda sorta got a login flow working

+21
.idea/appInsightsSettings.xml
··· 2 2 <project version="4"> 3 3 <component name="AppInsightsSettings"> 4 4 <option name="selectedTabId" value="Firebase Crashlytics" /> 5 + <option name="tabSettings"> 6 + <map> 7 + <entry key="Firebase Crashlytics"> 8 + <value> 9 + <InsightsFilterSettings> 10 + <option name="connection"> 11 + <ConnectionSetting> 12 + <option name="appId" value="PLACEHOLDER" /> 13 + <option name="mobileSdkAppId" value="" /> 14 + <option name="projectId" value="" /> 15 + <option name="projectNumber" value="" /> 16 + </ConnectionSetting> 17 + </option> 18 + <option name="signal" value="SIGNAL_UNSPECIFIED" /> 19 + <option name="timeIntervalDays" value="THIRTY_DAYS" /> 20 + <option name="visibilityType" value="ALL" /> 21 + </InsightsFilterSettings> 22 + </value> 23 + </entry> 24 + </map> 25 + </option> 5 26 </component> 6 27 </project>
+2 -8
.idea/deploymentTargetSelector.xml
··· 4 4 <selectionStates> 5 5 <SelectionState runConfigName="app"> 6 6 <option name="selectionMode" value="DROPDOWN" /> 7 - </SelectionState> 8 - <SelectionState runConfigName="MainActivity"> 9 - <option name="selectionMode" value="DROPDOWN" /> 10 - </SelectionState> 11 - <SelectionState runConfigName="MainActivity (1)"> 12 - <option name="selectionMode" value="DROPDOWN" /> 13 - <DropdownSelection timestamp="2025-09-25T16:00:45.132665900Z"> 7 + <DropdownSelection timestamp="2025-09-26T22:09:54.036774Z"> 14 8 <Target type="DEFAULT_BOOT"> 15 9 <handle> 16 - <DeviceId pluginId="PhysicalDevice" identifier="serial=57141FDCH007E3" /> 10 + <DeviceId pluginId="LocalEmulator" identifier="path=/Users/gsora/.android/avd/Medium_Phone.avd" /> 17 11 </handle> 18 12 </Target> 19 13 </DropdownSelection>
+8
.idea/dictionaries/project.xml
··· 1 + <component name="ProjectDictionaryState"> 2 + <dictionary name="project"> 3 + <words> 4 + <w>bsky</w> 5 + <w>skeets</w> 6 + </words> 7 + </dictionary> 8 + </component>
+6
.idea/studiobot.xml
··· 1 + <?xml version="1.0" encoding="UTF-8"?> 2 + <project version="4"> 3 + <component name="StudioBotProjectSettings"> 4 + <option name="shareContext" value="OptedIn" /> 5 + </component> 6 + </project>
+6
.idea/vcs.xml
··· 1 + <?xml version="1.0" encoding="UTF-8"?> 2 + <project version="4"> 3 + <component name="VcsDirectoryMappings"> 4 + <mapping directory="" vcs="Git" /> 5 + </component> 6 + </project>
+9 -1
app/build.gradle.kts
··· 4 4 alias(libs.plugins.android.application) 5 5 alias(libs.plugins.kotlin.android) 6 6 alias(libs.plugins.kotlin.compose) 7 + kotlin("plugin.serialization") 7 8 } 8 9 9 10 android { ··· 60 61 implementation("com.google.dagger:hilt-android:2.57.1") 61 62 implementation("androidx.hilt:hilt-navigation-compose:1.3.0") 62 63 implementation("androidx.compose.material3:material3-adaptive-navigation-suite") 63 - ksp("com.google.dagger:hilt-compiler:2.57.1") 64 + implementation("androidx.compose.material:material-icons-extended") 65 + implementation("androidx.datastore:datastore-preferences:1.1.7") 66 + implementation("androidx.datastore:datastore:1.1.7") 67 + implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.9.0") 68 + implementation(platform("androidx.compose:compose-bom:2025.09.00")) 69 + implementation("androidx.paging:paging-compose:3.3.0-alpha05") 70 + implementation(libs.androidx.compose.animation.core.lint) 71 + ksp("com.google.dagger:hilt-compiler:2.57.2") 64 72 implementation(libs.androidx.core.ktx) 65 73 implementation(libs.androidx.lifecycle.runtime.ktx) 66 74 implementation(libs.androidx.activity.compose)
+5 -9
app/src/main/java/industries/geesawra/jerryno/ComposeView.kt
··· 36 36 import androidx.compose.ui.focus.focusRequester 37 37 import androidx.compose.ui.text.font.FontWeight 38 38 import androidx.compose.ui.unit.dp 39 - import industries.geesawra.jerryno.datalayer.Bluesky 40 - import kotlinx.coroutines.CoroutineScope 41 - import kotlinx.coroutines.launch 39 + import industries.geesawra.jerryno.datalayer.TimelineViewModel 42 40 43 41 @OptIn(ExperimentalMaterial3Api::class) 44 42 @Composable 45 43 fun ComposeView( 46 44 modalSheetState: SheetState, 47 45 focusRequester: FocusRequester, 48 - coroutineScope: CoroutineScope, 49 - bluesky: Bluesky, 46 + timelineViewModel: TimelineViewModel, 50 47 onDismissRequest: () -> Unit, 51 48 ) { 52 49 var text by rememberSaveable { mutableStateOf("") } ··· 97 94 Button( 98 95 enabled = text.isNotBlank() && text.length <= maxChars, 99 96 onClick = { 100 - coroutineScope.launch { 101 - bluesky.post(text) 102 - modalSheetState.hide() // Animate the sheet away 103 - } 97 + timelineViewModel.post(content = text, then = { 98 + modalSheetState.hide() 99 + }) 104 100 }, 105 101 modifier = Modifier.padding(4.dp) 106 102 ) {
+119
app/src/main/java/industries/geesawra/jerryno/LoginView.kt
··· 1 + package industries.geesawra.jerryno 2 + 3 + import android.util.Log 4 + import android.widget.Toast 5 + import androidx.compose.foundation.layout.Column 6 + import androidx.compose.foundation.layout.fillMaxWidth 7 + import androidx.compose.foundation.layout.padding 8 + import androidx.compose.foundation.text.KeyboardOptions 9 + import androidx.compose.material.icons.Icons 10 + import androidx.compose.material.icons.filled.Visibility 11 + import androidx.compose.material.icons.filled.VisibilityOff 12 + import androidx.compose.material3.Button 13 + import androidx.compose.material3.Icon 14 + import androidx.compose.material3.IconButton 15 + import androidx.compose.material3.MaterialTheme 16 + import androidx.compose.material3.Text 17 + import androidx.compose.material3.TextField 18 + import androidx.compose.runtime.Composable 19 + import androidx.compose.runtime.getValue 20 + import androidx.compose.runtime.mutableStateOf 21 + import androidx.compose.runtime.remember 22 + import androidx.compose.runtime.rememberCoroutineScope 23 + import androidx.compose.runtime.setValue 24 + import androidx.compose.ui.Alignment 25 + import androidx.compose.ui.Modifier 26 + import androidx.compose.ui.focus.onFocusChanged 27 + import androidx.compose.ui.platform.LocalContext 28 + import androidx.compose.ui.text.input.KeyboardType 29 + import androidx.compose.ui.text.input.PasswordVisualTransformation 30 + import androidx.compose.ui.text.input.VisualTransformation 31 + import androidx.compose.ui.unit.dp 32 + import industries.geesawra.jerryno.datalayer.BlueskyConn 33 + import kotlinx.coroutines.launch 34 + import sh.christian.ozone.api.Handle 35 + 36 + @Composable 37 + fun LoginView( 38 + modifier: Modifier = Modifier, 39 + navigate: () -> Unit 40 + ) { 41 + var handle by remember { mutableStateOf("") } 42 + var password by remember { mutableStateOf("") } 43 + var isPasswordFocused by remember { mutableStateOf(false) } 44 + var passwordVisible by remember { mutableStateOf(false) } 45 + val scope = rememberCoroutineScope() 46 + val ctx = LocalContext.current 47 + val bc = BlueskyConn(ctx) 48 + val appPasswordRegex = "[a-zA-Z0-9]{4}-[a-zA-Z0-9]{4}-[a-zA-Z0-9]{4}-[a-zA-Z0-9]{4}".toRegex() 49 + 50 + Column( 51 + modifier = modifier 52 + .fillMaxWidth() 53 + .padding(start = 8.dp, end = 8.dp), 54 + horizontalAlignment = Alignment.CenterHorizontally 55 + ) { 56 + Text( 57 + text = "Log into your Bluesky account", 58 + style = MaterialTheme.typography.titleLarge, 59 + modifier = Modifier.padding(bottom = 16.dp) 60 + ) 61 + TextField( 62 + value = handle, 63 + onValueChange = { handle = it }, 64 + label = { Text("Handle (e.g., yourname.bsky.social)") }, 65 + modifier = Modifier.fillMaxWidth() 66 + ) 67 + TextField( 68 + value = password, 69 + onValueChange = { password = it }, 70 + label = { Text("Password") }, 71 + visualTransformation = if (passwordVisible) VisualTransformation.None else PasswordVisualTransformation(), 72 + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password), 73 + trailingIcon = { 74 + val image = if (passwordVisible) 75 + Icons.Filled.Visibility 76 + else Icons.Filled.VisibilityOff 77 + val description = if (passwordVisible) "Hide password" else "Show password" 78 + IconButton(onClick = { passwordVisible = !passwordVisible }) { 79 + Icon(imageVector = image, description) 80 + } 81 + }, 82 + modifier = Modifier 83 + .fillMaxWidth() 84 + .padding(top = 8.dp) 85 + .onFocusChanged { focusState -> isPasswordFocused = focusState.isFocused } 86 + ) 87 + 88 + Button( 89 + onClick = { 90 + scope.launch { 91 + bc.login("https://wallera.computer", handle, password).onSuccess { 92 + navigate() 93 + }.onFailure { 94 + Log.e("LoginView", "Login failed", it) 95 + Toast.makeText(ctx, it.message ?: "Unknown login error", Toast.LENGTH_LONG) 96 + .show() 97 + } 98 + } 99 + }, 100 + enabled = (Handle.Regex.matches(handle.removePrefix("@"))) && password.isNotEmpty(), 101 + modifier = Modifier.padding(top = 8.dp, bottom = 8.dp) 102 + ) { 103 + Text("Login") 104 + } 105 + 106 + Text( 107 + text = "I'll look up your PDS automatically :^)", 108 + style = MaterialTheme.typography.labelSmall 109 + ) 110 + 111 + if (password.isNotEmpty() && !password.matches(appPasswordRegex) && !isPasswordFocused) { 112 + Text( 113 + text = "Hint: Consider using an app password (e.g., xxxx-xxxx-xxxx-xxxx).", 114 + style = MaterialTheme.typography.labelSmall, 115 + modifier = Modifier.padding(top = 4.dp) 116 + ) 117 + } 118 + } 119 + }
+65 -115
app/src/main/java/industries/geesawra/jerryno/MainActivity.kt
··· 2 2 3 3 import android.app.Application 4 4 import android.os.Bundle 5 - import android.util.Log 6 5 import androidx.activity.ComponentActivity 7 - import androidx.activity.compose.rememberLauncherForActivityResult 8 6 import androidx.activity.compose.setContent 9 7 import androidx.activity.enableEdgeToEdge 10 - import androidx.activity.result.PickVisualMediaRequest 11 - import androidx.activity.result.contract.ActivityResultContracts 12 8 import androidx.annotation.StringRes 13 - import androidx.compose.animation.AnimatedContentTransitionScope 14 - import androidx.compose.animation.EnterTransition 15 - import androidx.compose.animation.ExitTransition 16 9 import androidx.compose.animation.ExperimentalSharedTransitionApi 17 - import androidx.compose.animation.SharedTransitionLayout 18 - import androidx.compose.animation.core.EaseIn 19 - import androidx.compose.animation.core.EaseOut 20 - import androidx.compose.animation.core.LinearEasing 21 - import androidx.compose.animation.core.tween 22 - import androidx.compose.animation.fadeIn 23 - import androidx.compose.animation.fadeOut 24 - import androidx.compose.foundation.border 25 - import androidx.compose.foundation.clickable 26 10 import androidx.compose.foundation.layout.Arrangement 27 11 import androidx.compose.foundation.layout.Box 28 - import androidx.compose.foundation.layout.Column 29 - import androidx.compose.foundation.layout.PaddingValues 30 - import androidx.compose.foundation.layout.Row 31 - import androidx.compose.foundation.layout.WindowInsets 32 - import androidx.compose.foundation.layout.fillMaxHeight 33 12 import androidx.compose.foundation.layout.fillMaxSize 34 - import androidx.compose.foundation.layout.fillMaxWidth 35 - import androidx.compose.foundation.layout.height 36 - import androidx.compose.foundation.layout.ime 37 - import androidx.compose.foundation.layout.imePadding // Added import 38 13 import androidx.compose.foundation.layout.padding 39 - import androidx.compose.foundation.layout.size 40 - import androidx.compose.foundation.layout.sizeIn 41 14 import androidx.compose.foundation.layout.width 42 15 import androidx.compose.foundation.lazy.LazyColumn 43 - import androidx.compose.foundation.lazy.grid.GridCells 44 - import androidx.compose.foundation.lazy.grid.LazyVerticalGrid 45 - import androidx.compose.foundation.shape.RoundedCornerShape 16 + import androidx.compose.foundation.lazy.LazyListState 17 + import androidx.compose.foundation.lazy.rememberLazyListState 46 18 import androidx.compose.material.icons.Icons 47 - import androidx.compose.material.icons.automirrored.filled.Send 48 - import androidx.compose.material.icons.filled.Add 49 19 import androidx.compose.material.icons.filled.Create 50 20 import androidx.compose.material.icons.filled.Home 51 21 import androidx.compose.material.icons.filled.Notifications 52 - import androidx.compose.material3.Button 53 - import androidx.compose.material3.Card 54 22 import androidx.compose.material3.CircularProgressIndicator 55 23 import androidx.compose.material3.ExperimentalMaterial3Api 56 24 import androidx.compose.material3.FloatingActionButton 57 25 import androidx.compose.material3.Icon 58 26 import androidx.compose.material3.MaterialTheme 59 27 import androidx.compose.material3.MediumTopAppBar 60 - import androidx.compose.material3.ModalBottomSheet 61 28 import androidx.compose.material3.Scaffold 62 29 import androidx.compose.material3.SheetValue 63 30 import androidx.compose.material3.Surface 64 31 import androidx.compose.material3.Text 65 - import androidx.compose.material3.TextField 66 32 import androidx.compose.material3.TopAppBarColors 67 33 import androidx.compose.material3.TopAppBarDefaults 68 34 import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteScaffold ··· 79 45 import androidx.compose.runtime.setValue 80 46 import androidx.compose.ui.Alignment 81 47 import androidx.compose.ui.Modifier 82 - import androidx.compose.ui.draw.clip 83 48 import androidx.compose.ui.focus.FocusRequester 84 - import androidx.compose.ui.focus.focusRequester 85 49 import androidx.compose.ui.graphics.vector.ImageVector 86 50 import androidx.compose.ui.input.nestedscroll.nestedScroll 87 - import androidx.compose.ui.layout.ContentScale 88 51 import androidx.compose.ui.platform.LocalContext 89 - import androidx.compose.ui.platform.LocalSoftwareKeyboardController 90 52 import androidx.compose.ui.res.stringResource 91 - import androidx.compose.ui.text.font.FontWeight 92 53 import androidx.compose.ui.unit.dp 93 54 import androidx.compose.ui.viewinterop.AndroidView 94 55 import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel 95 - import androidx.lifecycle.ViewModel 96 - import androidx.lifecycle.viewModelScope 97 - import androidx.lifecycle.viewmodel.compose.viewModel 98 56 import androidx.media3.common.MediaItem 99 57 import androidx.media3.exoplayer.ExoPlayer 100 58 import androidx.media3.ui.PlayerView 101 59 import androidx.navigation.compose.NavHost 102 60 import androidx.navigation.compose.composable 103 61 import androidx.navigation.compose.rememberNavController 62 + import androidx.paging.Pager 63 + import androidx.paging.compose.collectAsLazyPagingItems 104 64 import app.bsky.feed.FeedViewPost 105 - import app.bsky.feed.FeedViewPostReasonUnion 106 - import app.bsky.feed.Post 107 - import app.bsky.feed.PostViewEmbedUnion 108 - import app.bsky.feed.ReplyRefParentUnion 109 - import coil3.compose.AsyncImage 110 - import coil3.request.ImageRequest 111 - import coil3.request.crossfade 112 - import dagger.assisted.Assisted 113 - import dagger.assisted.AssistedFactory 114 - import dagger.assisted.AssistedInject 115 65 import dagger.hilt.android.AndroidEntryPoint 116 66 import dagger.hilt.android.HiltAndroidApp 117 - import dagger.hilt.android.lifecycle.HiltViewModel 118 - import industries.geesawra.jerryno.datalayer.Bluesky 67 + import industries.geesawra.jerryno.datalayer.BlueskyConn 68 + import industries.geesawra.jerryno.datalayer.TimelineViewModel 119 69 import industries.geesawra.jerryno.ui.theme.JerryNoTheme 120 - import kotlinx.coroutines.Job 121 - import kotlinx.coroutines.launch 122 - import kotlinx.serialization.json.decodeFromJsonElement 123 - import sh.christian.ozone.BlueskyJson 124 70 125 71 126 72 @HiltAndroidApp 127 73 class Application : Application() {} 128 74 129 75 enum class TimelineScreen() { 76 + Login, 130 77 Timeline, 131 78 Compose 132 79 } 133 80 134 81 enum class TabBarDestinations( 135 - @StringRes val label: Int, 82 + @param:StringRes val label: Int, 136 83 val icon: ImageVector, 137 - @StringRes val contentDescription: Int 84 + @param:StringRes val contentDescription: Int 138 85 ) { 139 86 HOME(R.string.timeline, Icons.Filled.Home, R.string.timeline), 140 87 NOTIFICATIONS(R.string.notifications, Icons.Filled.Notifications, R.string.notifications) ··· 153 100 val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior( 154 101 rememberTopAppBarState() 155 102 ) 156 - 157 - val bluesky = Bluesky( 158 - "https://wallera.computer", 159 - // TODO: login here 160 - ) 161 - 103 + val conn = BlueskyConn(LocalContext.current) 162 104 val timelineViewModel = hiltViewModel<TimelineViewModel, TimelineViewModel.Factory>( 163 105 creationCallback = { factory -> 164 - factory.create(bluesky) 106 + factory.create(conn) 165 107 } 166 108 ) 167 - 168 109 var currentDestination by rememberSaveable { mutableStateOf(TabBarDestinations.HOME) } 169 110 val navController = rememberNavController() 170 - 111 + val loggingIn = remember { mutableStateOf(true) } 171 112 val modalSheetState = rememberModalBottomSheetState( 172 113 skipPartiallyExpanded = true, 173 114 confirmValueChange = { sv -> ··· 183 124 ) { 184 125 NavigationSuiteScaffold( 185 126 navigationSuiteItems = { 127 + if (loggingIn.value) { 128 + return@NavigationSuiteScaffold 129 + } 130 + 186 131 TabBarDestinations.entries.forEach { 187 132 item( 188 133 icon = { ··· 204 149 .fillMaxSize() 205 150 .nestedScroll(scrollBehavior.nestedScrollConnection), 206 151 topBar = { 152 + if (loggingIn.value) { 153 + return@Scaffold 154 + } 155 + 207 156 MediumTopAppBar( 208 157 colors = TopAppBarColors( 209 158 containerColor = MaterialTheme.colorScheme.surfaceContainer, ··· 216 165 Text(text = "Jerry No") 217 166 }, 218 167 scrollBehavior = scrollBehavior 219 - 220 168 ) 221 169 }, 222 170 floatingActionButton = { 171 + if (loggingIn.value) { 172 + return@Scaffold 173 + } 174 + 223 175 FloatingActionButton( 224 176 onClick = { 225 - // showBottomSheet = true 226 177 navController.navigate(TimelineScreen.Compose.name) 227 178 }, 228 179 ) { ··· 230 181 } 231 182 }, 232 183 ) { values -> 184 + 185 + timelineViewModel.loadSession() 186 + if (!timelineViewModel.uiState.sessionChecked) { 187 + Box( 188 + modifier = Modifier.fillMaxSize(), // Make the Box take the full available space 189 + contentAlignment = Alignment.Center // Align content (LoginView) to the center 190 + ) { 191 + } 192 + 193 + return@Scaffold 194 + } 195 + 196 + val initialRoute = 197 + if (timelineViewModel.uiState.authenticated) TimelineScreen.Timeline.name else TimelineScreen.Login.name 198 + 233 199 NavHost( 234 200 navController = navController, 235 - startDestination = TimelineScreen.Timeline.name, 201 + startDestination = initialRoute, 236 202 modifier = Modifier.padding(values) 237 203 ) { 238 204 composable(route = TimelineScreen.Timeline.name) { 205 + loggingIn.value = false 206 + timelineViewModel.create() 207 + 208 + val listState = rememberLazyListState() 209 + listState.canScrollBackward 210 + 239 211 ShowSkeets( 240 - viewModel = timelineViewModel 212 + viewModel = timelineViewModel, 213 + state = listState 241 214 ) 242 215 243 216 if (showBottomSheet) { ··· 245 218 modalSheetState = modalSheetState, 246 219 focusRequester = focusRequester, 247 220 coroutineScope = coroutineScope, 248 - bluesky = bluesky, 221 + timelineViewModel = timelineViewModel, 249 222 onDismissRequest = { 250 223 showBottomSheet = false 251 224 } ··· 259 232 .fillMaxSize() 260 233 .padding(10.dp) 261 234 ) 235 + } 236 + composable(route = TimelineScreen.Login.name) { 237 + Box( 238 + modifier = Modifier.fillMaxSize(), // Make the Box take the full available space 239 + contentAlignment = Alignment.Center // Align content (LoginView) to the center 240 + ) { 241 + LoginView { 242 + navController.navigate(TimelineScreen.Timeline.name) 243 + } 244 + } 262 245 } 263 246 } 264 - 265 247 } 266 248 } 267 249 } ··· 309 291 310 292 @Composable 311 293 fun ShowSkeets( 312 - viewModel: TimelineViewModel 294 + viewModel: TimelineViewModel, 295 + pager: Pager<Int, FeedViewPost>, 296 + state: LazyListState = rememberLazyListState() 313 297 ) { 298 + val lazyPagingItems = pager.flow.collectAsLazyPagingItems() 299 + 314 300 viewModel.fetchTimeline() 315 301 316 302 LazyColumn( 303 + state = state, 317 304 modifier = Modifier 318 305 .fillMaxSize(), 319 306 verticalArrangement = Arrangement.spacedBy(8.dp), ··· 328 315 CircularProgressIndicator( 329 316 modifier = Modifier 330 317 .width(64.dp), 331 - color = MaterialTheme.colorScheme.onPrimaryContainer, 332 - trackColor = MaterialTheme.colorScheme.onPrimary, 333 318 ) 334 319 } 335 320 } ··· 344 329 } 345 330 } 346 331 347 - data class TimelineUiState( 348 - val skeets: List<FeedViewPost> = listOf() 349 - ) 350 - 351 - @HiltViewModel(assistedFactory = TimelineViewModel.Factory::class) 352 - class TimelineViewModel @AssistedInject constructor( 353 - @Assisted private val bsky: Bluesky 354 - ) : ViewModel() { 355 - 356 - @AssistedFactory 357 - interface Factory { 358 - fun create(bsky: Bluesky): TimelineViewModel 359 - } 360 - 361 - var uiState by mutableStateOf(TimelineUiState()) 362 - private set 363 - 364 - private var fetchJob: Job? = null 365 - 366 - fun fetchTimeline() { 367 - fetchJob?.cancel() 368 - 369 - fetchJob = viewModelScope.launch { 370 - bsky.fetchTimeline().onSuccess { 371 - uiState = uiState.copy(skeets = it.feed) 372 - }.onFailure { Log.e("TimelineViewModel", "Failed to fetch timeline: ${it.message}") } 373 - } 374 - } 375 - 376 - fun post(content: String) { 377 - viewModelScope.launch { 378 - bsky.post(content) 379 - } 380 - } 381 - }
+11 -2
app/src/main/java/industries/geesawra/jerryno/SkeetRowView.kt
··· 170 170 modifier = Modifier 171 171 .height(200.dp) 172 172 .fillMaxWidth() 173 - .padding(top = 4.dp), 173 + .padding(4.dp), 174 174 userScrollEnabled = false, 175 175 content = { 176 176 items(img.size) { index -> 177 - val img = img.get(index) 177 + val img = img[index] 178 178 179 179 AsyncImage( 180 180 model = ImageRequest.Builder(LocalContext.current) ··· 185 185 contentDescription = img.alt, 186 186 modifier = Modifier 187 187 .height(200.dp) 188 + .padding( 189 + start = 4.dp, 190 + end = 4.dp, 191 + bottom = 10.dp 192 + ) 193 + .clip(RoundedCornerShape(12.dp)) 194 + .dropShadow(shape = RoundedCornerShape(12.dp), block = { 195 + radius = 2f 196 + }) 188 197 .fillMaxWidth() 189 198 ) 190 199 }
+193 -40
app/src/main/java/industries/geesawra/jerryno/datalayer/Bluesky.kt
··· 1 1 package industries.geesawra.jerryno.datalayer 2 2 3 - import android.util.Log 4 - import androidx.lifecycle.viewModelScope 3 + import android.content.Context 4 + import androidx.compose.ui.text.intl.Locale 5 + import androidx.compose.ui.text.toLowerCase 6 + import androidx.datastore.preferences.core.edit 7 + import androidx.datastore.preferences.core.stringPreferencesKey 8 + import androidx.datastore.preferences.preferencesDataStore 5 9 import app.bsky.feed.GetTimelineQueryParams 6 10 import app.bsky.feed.GetTimelineResponse 7 11 import app.bsky.feed.Post 8 12 import com.atproto.repo.CreateRecordRequest 9 13 import com.atproto.server.CreateSessionRequest 10 14 import com.atproto.server.CreateSessionResponse 15 + import com.atproto.server.GetSessionResponse 16 + import com.atproto.server.RefreshSessionResponse 11 17 import io.ktor.client.HttpClient 12 18 import io.ktor.client.engine.okhttp.OkHttp 13 19 import io.ktor.client.plugins.HttpTimeout 14 20 import io.ktor.client.plugins.defaultRequest 15 - import kotlinx.coroutines.launch 21 + import kotlinx.coroutines.flow.Flow 22 + import kotlinx.coroutines.flow.first 23 + import kotlinx.coroutines.flow.map 16 24 import kotlinx.datetime.Clock 25 + import kotlinx.serialization.Serializable 17 26 import sh.christian.ozone.BlueskyJson 18 27 import sh.christian.ozone.XrpcBlueskyApi 19 28 import sh.christian.ozone.api.AuthenticatedXrpcBlueskyApi 20 29 import sh.christian.ozone.api.BlueskyAuthPlugin 30 + import sh.christian.ozone.api.Did 31 + import sh.christian.ozone.api.Handle 21 32 import sh.christian.ozone.api.Nsid 22 33 import sh.christian.ozone.api.model.JsonContent.Companion.encodeAsJsonContent 23 34 import sh.christian.ozone.api.response.AtpResponse 24 - import javax.inject.Inject 25 35 26 36 data class Client( 27 37 var client: AuthenticatedXrpcBlueskyApi, 28 - var session: CreateSessionResponse 38 + var session: SessionData 29 39 ) 30 40 31 - private class BlueskyConn() { 41 + enum class AuthData { 42 + PDSHost, 43 + SessionData, 44 + } 45 + 46 + @Serializable // Added annotation 47 + data class SessionData( 48 + val accessJwt: String, 49 + val refreshJwt: String, 50 + val handle: Handle, 51 + val did: Did, 52 + val active: Boolean? = null, 53 + ) { 54 + fun encodeToJson(): String { 55 + return BlueskyJson.encodeToString(serializer(), this) 56 + } 57 + 58 + companion object { 59 + fun decodeFromJson(jsonString: String): SessionData { 60 + return BlueskyJson.decodeFromString(serializer(), jsonString) 61 + } 62 + 63 + fun fromCreateSessionResponse(session: CreateSessionResponse): SessionData { 64 + return SessionData( 65 + accessJwt = session.accessJwt, 66 + refreshJwt = session.refreshJwt, 67 + handle = session.handle, 68 + did = session.did, 69 + active = session.active, 70 + ) 71 + } 72 + 73 + fun fromRefreshSessionResponse(session: RefreshSessionResponse): SessionData { 74 + return SessionData( 75 + accessJwt = session.accessJwt, 76 + refreshJwt = session.refreshJwt, 77 + handle = session.handle, 78 + did = session.did, 79 + active = session.active, 80 + ) 81 + } 82 + 83 + fun fromGetSessionResponse(session: GetSessionResponse): SessionData { 84 + return SessionData( 85 + handle = session.handle, 86 + did = session.did, 87 + active = session.active, 88 + accessJwt = "", 89 + refreshJwt = "" 90 + ) 91 + } 92 + } 93 + } 94 + 95 + 96 + class BlueskyConn(val context: Context) { 97 + companion object { 98 + private val Context.dataStore by preferencesDataStore("bluesky") 99 + private val SESSION = stringPreferencesKey(AuthData.SessionData.name) 100 + private val PDSHOST = stringPreferencesKey(AuthData.PDSHost.name) 101 + } 102 + 32 103 var client: AuthenticatedXrpcBlueskyApi? = null 33 - var session: CreateSessionResponse? = null 104 + var session: SessionData? = null 105 + 106 + suspend fun storeSessionData(pdsURL: String, session: SessionData) { 107 + context.dataStore.edit { settings -> 108 + settings[SESSION] = session.encodeToJson() 109 + settings[PDSHOST] = pdsURL 110 + } 111 + } 112 + 113 + suspend fun hasSession(): Boolean { 114 + val pdsURLFlow: Flow<String> = context.dataStore.data.map { settings -> 115 + settings[PDSHOST] ?: "" 116 + } 117 + val sessionDataStringFlow: Flow<String> = context.dataStore.data.map { settings -> 118 + settings[SESSION] ?: "" 119 + } 120 + 121 + val pdsURL = pdsURLFlow.first() 122 + val sessionDataString = sessionDataStringFlow.first() 123 + 124 + return !(pdsURL.isEmpty() || sessionDataString.isEmpty()) 125 + } 126 + 127 + suspend fun login(pdsURL: String, handle: String, password: String): Result<Unit> { 128 + val httpClient = HttpClient(OkHttp) { 129 + defaultRequest { 130 + url(pdsURL) 131 + } 132 + install(HttpTimeout) { 133 + requestTimeoutMillis = 15000 134 + connectTimeoutMillis = 15000 135 + socketTimeoutMillis = 15000 136 + } 137 + } 138 + 139 + val client = XrpcBlueskyApi(httpClient) 140 + 141 + val s = client.createSession(CreateSessionRequest(handle, password)) 142 + val sessionResponse: CreateSessionResponse = when (s) { 143 + is AtpResponse.Failure<*> -> return Result.failure( 144 + Exception( 145 + "Failed to create session: ${ 146 + s.error?.message?.toLowerCase( 147 + Locale.current 148 + ) 149 + }" 150 + ) 151 + ) 152 + 153 + is AtpResponse.Success<CreateSessionResponse> -> s.response 154 + } 155 + 156 + storeSessionData(pdsURL, SessionData.fromCreateSessionResponse(sessionResponse)) 34 157 35 - suspend fun create(pdsURL: String, handle: String, password: String): Result<Client> { 158 + return Result.success(Unit) 159 + } 160 + 161 + suspend fun create(): Result<Unit> { 36 162 return runCatching { 37 - if (client != null && session != null) { 38 - return Result.success(Client(client!!, session!!)) 163 + if (session != null && client != null) { 164 + return Result.success(Unit) 39 165 } 40 166 167 + val pdsURLFlow: Flow<String> = context.dataStore.data.map { settings -> 168 + settings[PDSHOST] ?: "" 169 + } 170 + val sessionDataStringFlow: Flow<String> = context.dataStore.data.map { settings -> 171 + settings[SESSION] ?: "" 172 + } 173 + 174 + val pdsURL = pdsURLFlow.first() 175 + val sessionDataString = sessionDataStringFlow.first() 176 + 177 + if (pdsURL.isEmpty() || sessionDataString.isEmpty()) { 178 + return Result.failure(Exception("No session data found")) 179 + } 180 + 181 + val sessionData = SessionData.decodeFromJson(sessionDataString) 182 + 183 + 41 184 val httpClient = HttpClient(OkHttp) { 42 185 defaultRequest { 43 186 url(pdsURL) ··· 49 192 } 50 193 } 51 194 52 - val client = XrpcBlueskyApi(httpClient) 195 + val tokens = 196 + BlueskyAuthPlugin.Tokens(sessionData.accessJwt, sessionData.refreshJwt) 197 + val authClient = AuthenticatedXrpcBlueskyApi(httpClient, tokens) 53 198 54 - val s = client.createSession(CreateSessionRequest(handle, password)) 55 - when (s) { 56 - is AtpResponse.Failure<*> -> Result.failure<Exception>(Exception("Failed to create session: ${s.error}")) 57 - is AtpResponse.Success<CreateSessionResponse> -> { 58 - val tokens = BlueskyAuthPlugin.Tokens(s.response.accessJwt, s.response.refreshJwt) 59 - val authClient = AuthenticatedXrpcBlueskyApi(httpClient, tokens) 60 - this.client = authClient 61 - session = s.response 62 - } 199 + val gs = authClient.getSession().maybeResponse() 200 + gs?.let { 201 + this.client = authClient 202 + this.session = SessionData.fromGetSessionResponse(it) 203 + 204 + return Result.success(Unit) 205 + } 206 + 207 + // No session, try to refresh 208 + val rs = authClient.refreshSession().maybeResponse() 209 + rs?.let { 210 + this.client = authClient 211 + this.session = SessionData.fromRefreshSessionResponse(it) 212 + 213 + return Result.success(Unit) 63 214 } 64 215 65 - return Result.success(Client(this.client!!, session!!)) 216 + return Result.failure(Exception("Could not refresh session, maybe login again?")) 66 217 } 67 218 } 68 - } 69 - 70 - class Bluesky(val pdsURL: String, val handle: String, val password: String) { 71 - private val conn = BlueskyConn() 72 219 73 - suspend fun fetchTimeline(): Result<GetTimelineResponse> { 220 + suspend fun fetchTimeline(cursor: String? = null): Result<GetTimelineResponse> { 74 221 return runCatching { 75 - val conn = conn.create(pdsURL, handle, password).getOrThrow() 76 - 77 - val timeline = conn.client.getTimeline(GetTimelineQueryParams()); 222 + create().getOrThrow() 223 + val timeline = client!!.getTimeline( 224 + GetTimelineQueryParams( 225 + cursor 226 + ) 227 + ); 78 228 val feed = when (timeline) { 79 229 is AtpResponse.Failure<*> -> { 80 230 return Result.failure(Exception("Failed to fetch timeline: ${timeline.error}")) ··· 87 237 } 88 238 } 89 239 90 - suspend fun post(content: String){ 91 - val conn = conn.create(pdsURL, handle, password).getOrThrow() 240 + suspend fun post(content: String) { 241 + create().getOrThrow() 92 242 93 - val r = BlueskyJson.encodeAsJsonContent(Post( 243 + val r = BlueskyJson.encodeAsJsonContent( 244 + Post( 94 245 text = content, 95 246 createdAt = Clock.System.now() 96 - )) 97 - val resp = conn.client.createRecord( 98 - CreateRecordRequest( 99 - repo = conn.session.handle, 100 - collection = Nsid("app.bsky.feed.post"), 101 - record = r, 102 - ) 103 - ) // TODO: finish 247 + ) 248 + ) 249 + client!!.createRecord( 250 + CreateRecordRequest( 251 + repo = session!!.handle, // Use handle from the session 252 + collection = Nsid("app.bsky.feed.post"), 253 + record = r, 254 + ) 255 + ) 256 + 104 257 105 258 } 106 259 }
+76
app/src/main/java/industries/geesawra/jerryno/datalayer/TimelineViewModel.kt
··· 1 + package industries.geesawra.jerryno.datalayer 2 + 3 + import android.util.Log 4 + import androidx.compose.runtime.getValue 5 + import androidx.compose.runtime.mutableStateOf 6 + import androidx.compose.runtime.setValue 7 + import androidx.lifecycle.ViewModel 8 + import androidx.lifecycle.viewModelScope 9 + import app.bsky.feed.FeedViewPost 10 + import dagger.assisted.Assisted 11 + import dagger.assisted.AssistedFactory 12 + import dagger.assisted.AssistedInject 13 + import dagger.hilt.android.lifecycle.HiltViewModel 14 + import kotlinx.coroutines.Job 15 + import kotlinx.coroutines.launch 16 + 17 + 18 + data class TimelineUiState( 19 + val skeets: List<FeedViewPost> = listOf(), 20 + val cursor: String? = null, 21 + val authenticated: Boolean = false, 22 + val sessionChecked: Boolean = false, 23 + val authError: String = "" 24 + ) 25 + 26 + @HiltViewModel(assistedFactory = TimelineViewModel.Factory::class) 27 + class TimelineViewModel @AssistedInject constructor( 28 + @Assisted private val bskyConn: BlueskyConn 29 + ) : ViewModel() { 30 + 31 + @AssistedFactory 32 + interface Factory { 33 + fun create(bskyConn: BlueskyConn): TimelineViewModel 34 + } 35 + 36 + var uiState by mutableStateOf(TimelineUiState()) 37 + private set 38 + 39 + private var fetchJob: Job? = null 40 + 41 + 42 + fun create() { 43 + viewModelScope.launch { 44 + bskyConn.create() 45 + } 46 + } 47 + 48 + fun loadSession() { 49 + viewModelScope.launch { 50 + if (!bskyConn.hasSession()) { 51 + uiState = uiState.copy(sessionChecked = true) 52 + return@launch 53 + } 54 + 55 + uiState = uiState.copy(authenticated = true, sessionChecked = true) 56 + } 57 + } 58 + 59 + 60 + fun fetchTimeline() { 61 + fetchJob?.cancel() 62 + 63 + fetchJob = viewModelScope.launch { 64 + bskyConn.fetchTimeline(uiState.cursor).onSuccess { 65 + uiState = uiState.copy(skeets = uiState.skeets + it.feed, cursor = it.cursor) 66 + }.onFailure { Log.e("TimelineViewModel", "Failed to fetch timeline: ${it.message}") } 67 + } 68 + } 69 + 70 + fun post(content: String, then: suspend () -> Unit) { 71 + viewModelScope.launch { 72 + bskyConn.post(content) 73 + then() 74 + } 75 + } 76 + }
+3 -2
build.gradle.kts
··· 3 3 alias(libs.plugins.android.application) apply false 4 4 alias(libs.plugins.kotlin.android) apply false 5 5 alias(libs.plugins.kotlin.compose) apply false 6 - id("com.google.dagger.hilt.android") version "2.57.1" apply false 7 - id("com.google.devtools.ksp") version "2.0.21-1.0.27" apply false 6 + id("com.google.dagger.hilt.android") version "2.57.2" apply false 7 + id("com.google.devtools.ksp") version "2.2.20-2.0.3" apply false 8 + kotlin("plugin.serialization") version "2.2.20" apply false 8 9 }
+2
gradle/libs.versions.toml
··· 8 8 lifecycleRuntimeKtx = "2.6.1" 9 9 activityCompose = "1.8.0" 10 10 composeBom = "2024.09.00" 11 + animationCoreLint = "1.9.2" 11 12 12 13 [libraries] 13 14 androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } ··· 24 25 androidx-compose-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" } 25 26 androidx-compose-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" } 26 27 androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" } 28 + androidx-compose-animation-core-lint = { group = "androidx.compose.animation", name = "animation-core-lint", version.ref = "animationCoreLint" } 27 29 28 30 [plugins] 29 31 android-application = { id = "com.android.application", version.ref = "agp" }