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

Configure Feed

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

*: that's me, I'm a Monarch butterfly now

+1222 -1227
+1 -1
.idea/.name
··· 1 - Jerry No 1 + Monarch
+2 -2
app/build.gradle.kts
··· 8 8 } 9 9 10 10 android { 11 - namespace = "industries.geesawra.jerryno" 11 + namespace = "industries.geesawra.monarch" 12 12 compileSdk = 36 13 13 14 14 defaultConfig { 15 - applicationId = "industries.geesawra.jerryno" 15 + applicationId = "industries.geesawra.monarch" 16 16 minSdk = 36 17 17 targetSdk = 36 18 18 versionCode = 1
+4 -6
app/src/androidTest/java/industries/geesawra/jerryno/ExampleInstrumentedTest.kt app/src/androidTest/java/industries/geesawra/monarch/ExampleInstrumentedTest.kt
··· 1 - package industries.geesawra.jerryno 1 + package industries.geesawra.monarch 2 2 3 - import androidx.test.platform.app.InstrumentationRegistry 4 3 import androidx.test.ext.junit.runners.AndroidJUnit4 5 - 4 + import androidx.test.platform.app.InstrumentationRegistry 5 + import org.junit.Assert.assertEquals 6 6 import org.junit.Test 7 7 import org.junit.runner.RunWith 8 - 9 - import org.junit.Assert.* 10 8 11 9 /** 12 10 * Instrumented test, which will execute on an Android device. ··· 19 17 fun useAppContext() { 20 18 // Context of the app under test. 21 19 val appContext = InstrumentationRegistry.getInstrumentation().targetContext 22 - assertEquals("industries.geesawra.jerryno", appContext.packageName) 20 + assertEquals("industries.geesawra.monarch", appContext.packageName) 23 21 } 24 22 }
+2 -3
app/src/main/AndroidManifest.xml
··· 11 11 android:icon="@mipmap/ic_launcher" 12 12 android:label="@string/app_name" 13 13 android:roundIcon="@mipmap/ic_launcher_round" 14 - android:screenOrientation="portrait" 15 14 android:supportsRtl="true" 16 - android:theme="@style/Theme.JerryNo"> 15 + android:theme="@style/Theme.Monarch"> 17 16 <activity 18 17 android:name=".MainActivity" 19 18 android:exported="true" 20 - android:theme="@style/Theme.JerryNo" 19 + android:theme="@style/Theme.Monarch" 21 20 android:windowSoftInputMode="adjustResize"> 22 21 <intent-filter> 23 22 <action android:name="android.intent.action.MAIN" />
+356 -356
app/src/main/java/industries/geesawra/jerryno/ComposeView.kt app/src/main/java/industries/geesawra/monarch/ComposeView.kt
··· 1 - package industries.geesawra.jerryno 2 - 3 - import android.content.Context 4 - import android.net.Uri 5 - import android.widget.Toast 6 - import androidx.activity.compose.ManagedActivityResultLauncher 7 - import androidx.activity.compose.rememberLauncherForActivityResult 8 - import androidx.activity.result.PickVisualMediaRequest 9 - import androidx.activity.result.contract.ActivityResultContracts 10 - import androidx.compose.foundation.ExperimentalFoundationApi 11 - import androidx.compose.foundation.ScrollState 12 - import androidx.compose.foundation.background 13 - import androidx.compose.foundation.content.MediaType 14 - import androidx.compose.foundation.content.ReceiveContentListener 15 - import androidx.compose.foundation.content.consume 16 - import androidx.compose.foundation.content.contentReceiver 17 - import androidx.compose.foundation.content.hasMediaType 18 - import androidx.compose.foundation.layout.Arrangement 19 - import androidx.compose.foundation.layout.Box 20 - import androidx.compose.foundation.layout.Column 21 - import androidx.compose.foundation.layout.Row 22 - import androidx.compose.foundation.layout.Spacer 23 - import androidx.compose.foundation.layout.WindowInsets 24 - import androidx.compose.foundation.layout.add 25 - import androidx.compose.foundation.layout.fillMaxSize 26 - import androidx.compose.foundation.layout.fillMaxWidth 27 - import androidx.compose.foundation.layout.height 28 - import androidx.compose.foundation.layout.heightIn 29 - import androidx.compose.foundation.layout.ime 30 - import androidx.compose.foundation.layout.navigationBars 31 - import androidx.compose.foundation.layout.padding 32 - import androidx.compose.foundation.layout.size 33 - import androidx.compose.foundation.layout.windowInsetsPadding 34 - import androidx.compose.foundation.text.input.TextFieldLineLimits 35 - import androidx.compose.foundation.text.input.clearText 36 - import androidx.compose.foundation.text.input.rememberTextFieldState 37 - import androidx.compose.foundation.verticalScroll 38 - import androidx.compose.material.icons.Icons 39 - import androidx.compose.material.icons.automirrored.filled.Send 40 - import androidx.compose.material.icons.filled.CameraRoll 41 - import androidx.compose.material3.BottomSheetScaffoldState 42 - import androidx.compose.material3.Button 43 - import androidx.compose.material3.ButtonDefaults 44 - import androidx.compose.material3.Card 45 - import androidx.compose.material3.CircularProgressIndicator 46 - import androidx.compose.material3.ExperimentalMaterial3Api 47 - import androidx.compose.material3.Icon 48 - import androidx.compose.material3.MaterialTheme 49 - import androidx.compose.material3.OutlinedCard 50 - import androidx.compose.material3.OutlinedTextField 51 - import androidx.compose.material3.Text 52 - import androidx.compose.material3.TextButton 53 - import androidx.compose.runtime.Composable 54 - import androidx.compose.runtime.LaunchedEffect 55 - import androidx.compose.runtime.MutableState 56 - import androidx.compose.runtime.mutableIntStateOf 57 - import androidx.compose.runtime.mutableStateOf 58 - import androidx.compose.runtime.remember 59 - import androidx.compose.ui.Alignment 60 - import androidx.compose.ui.Modifier 61 - import androidx.compose.ui.focus.FocusRequester 62 - import androidx.compose.ui.focus.focusRequester 63 - import androidx.compose.ui.graphics.Color 64 - import androidx.compose.ui.platform.LocalSoftwareKeyboardController 65 - import androidx.compose.ui.unit.dp 66 - import industries.geesawra.jerryno.datalayer.SkeetData 67 - import industries.geesawra.jerryno.datalayer.TimelineViewModel 68 - import kotlinx.coroutines.CoroutineScope 69 - import kotlinx.coroutines.launch 70 - 71 - @OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) 72 - @Composable 73 - fun ComposeView( 74 - context: Context, 75 - coroutineScope: CoroutineScope, 76 - timelineViewModel: TimelineViewModel, 77 - inReplyTo: MutableState<SkeetData?>, 78 - scaffoldState: BottomSheetScaffoldState, 79 - scrollState: ScrollState 80 - ) { 81 - val focusRequester = remember { FocusRequester() } 82 - val keyboardController = LocalSoftwareKeyboardController.current 83 - val charCount = remember { mutableIntStateOf(0) } 84 - val wasEdited = remember { mutableStateOf(false) } 85 - val maxChars = 300 86 - val composeFieldState = rememberTextFieldState( 87 - "" 88 - ) 89 - val mediaSelected = remember { mutableStateOf(mapOf<Uri, String?>()) } 90 - val mediaSelectedIsVideo = remember { mutableStateOf(false) } 91 - 92 - LaunchedEffect(scaffoldState.bottomSheetState.isVisible) { 93 - if (scaffoldState.bottomSheetState.isVisible) { 94 - keyboardController?.show() 95 - focusRequester.requestFocus() 96 - } else { 97 - keyboardController?.hide() 98 - // Reset state when sheet is hidden 99 - composeFieldState.clearText() 100 - charCount.intValue = 0 101 - inReplyTo.value = null 102 - mediaSelected.value = mapOf() 103 - mediaSelectedIsVideo.value = false 104 - 105 - } 106 - } 107 - 108 - // Remember the ReceiveContentListener object as it is created inside a Composable scope 109 - val receiveContentListener = remember { 110 - ReceiveContentListener { transferableContent -> 111 - when (transferableContent.hasMediaType(MediaType.Image)) { 112 - true -> transferableContent.consume { 113 - val uri = it.uri 114 - val mimeType: String? = context.contentResolver.getType(uri) 115 - mediaSelected.value = mapOf(Pair(uri, mimeType)) 116 - true 117 - } 118 - 119 - 120 - false -> transferableContent 121 - } 122 - } 123 - } 124 - 125 - 126 - val pickMedia = 127 - rememberLauncherForActivityResult(ActivityResultContracts.PickMultipleVisualMedia(maxItems = 4)) { uris -> 128 - if (uris.isEmpty()) { 129 - return@rememberLauncherForActivityResult 130 - } 131 - 132 - val urisMap = uris.associateWith { uri -> 133 - val mimeType: String? = context.contentResolver.getType(uri) 134 - mimeType?.let { 135 - if (it.startsWith("image/")) { 136 - return@associateWith "image" 137 - } else if (it.startsWith("video/")) { 138 - return@associateWith "video" 139 - } 140 - } 141 - null 142 - } 143 - 144 - val containsVideo = urisMap.values.any { it == "video" } 145 - 146 - if (urisMap.size > 1 && containsVideo) { 147 - Toast.makeText( 148 - context, 149 - "Can only post up to 1 video or 4 pictures. Video cannot be mixed with other media.", 150 - Toast.LENGTH_LONG 151 - ).show() 152 - return@rememberLauncherForActivityResult 153 - } 154 - if (containsVideo && urisMap.any { it.value == "image" }) { 155 - Toast.makeText( 156 - context, 157 - "Video cannot be mixed with other media.", 158 - Toast.LENGTH_LONG 159 - ).show() 160 - return@rememberLauncherForActivityResult 161 - } 162 - 163 - mediaSelectedIsVideo.value = containsVideo && urisMap.size == 1 164 - mediaSelected.value = urisMap.filterValues { it != null } 165 - } 166 - 167 - val uploadingPost = remember { mutableStateOf(false) } 168 - 169 - // Outer Box: Handles IME padding and general content padding for the whole sheet content 170 - Box( 171 - modifier = Modifier 172 - .fillMaxWidth() // Takes full width of the bottom sheet 173 - .windowInsetsPadding(WindowInsets.ime.add(WindowInsets.navigationBars)) 174 - .verticalScroll(scrollState) 175 - .padding(16.dp) // General padding around all content inside the sheet 176 - ) { 177 - // Inner Box: Fills the space provided by Outer Box, used for layering scrollable content and fixed buttons 178 - Box(modifier = Modifier.fillMaxSize()) { 179 - // Scrollable Content Column 180 - Column( 181 - modifier = Modifier 182 - .fillMaxWidth(), // Takes full width of the Inner Box 183 - horizontalAlignment = Alignment.CenterHorizontally 184 - ) { 185 - Row { 186 - Text( 187 - "New Post", 188 - style = MaterialTheme.typography.titleLarge, 189 - modifier = Modifier.padding(bottom = 16.dp) 190 - ) 191 - } 192 - 193 - inReplyTo.value?.let { 194 - OutlinedCard( 195 - modifier = Modifier.padding(8.dp) 196 - ) { 197 - SkeetView( 198 - modifier = Modifier 199 - .padding(8.dp) 200 - .background(Color.Transparent), 201 - skeet = it, 202 - nested = true, 203 - disableEmbeds = true 204 - ) 205 - } 206 - } 207 - 208 - LaunchedEffect(composeFieldState.text) { 209 - if (composeFieldState.text.isEmpty()) { 210 - wasEdited.value = false 211 - } else { 212 - wasEdited.value = true 213 - charCount.intValue = composeFieldState.text.length 214 - } 215 - } 216 - 217 - OutlinedTextField( 218 - modifier = Modifier 219 - .fillMaxWidth() 220 - .heightIn(min = 250.dp) 221 - .focusRequester(focusRequester) 222 - .contentReceiver(receiveContentListener), 223 - label = { 224 - if (wasEdited.value) { 225 - Text( 226 - text = "${maxChars - charCount.intValue}", 227 - color = if (composeFieldState.text.length > maxChars) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurface 228 - ) 229 - } else { 230 - Text( 231 - text = "Less cringe this time, okay?", 232 - ) 233 - } 234 - }, 235 - isError = composeFieldState.text.length > maxChars, 236 - lineLimits = TextFieldLineLimits.MultiLine(maxHeightInLines = 10), 237 - state = composeFieldState 238 - ) 239 - 240 - ActionRow( 241 - context, 242 - uploadingPost, 243 - pickMedia, 244 - composeFieldState.text.toString(), 245 - mediaSelected, 246 - mediaSelectedIsVideo, 247 - coroutineScope, 248 - maxChars, 249 - timelineViewModel, 250 - scaffoldState, 251 - inReplyTo.value 252 - ) 253 - 254 - Spacer(modifier = Modifier.height(8.dp)) 255 - 256 - if (mediaSelected.value.isNotEmpty()) { 257 - Card( 258 - modifier = Modifier 259 - .fillMaxWidth() 260 - .padding(vertical = 8.dp) 261 - ) { 262 - PostImageGallery( 263 - modifier = Modifier 264 - .fillMaxWidth() // Gallery should fill card width 265 - .padding(8.dp), 266 - images = mediaSelected.value.keys.map { uri -> 267 - Image(url = uri.toString(), alt = "Selected media") 268 - }, 269 - ) 270 - } 271 - } 272 - 273 - // Spacer at the end of scrollable content to prevent overlap with fixed buttons 274 - Spacer(modifier = Modifier.height(80.dp)) 275 - } 276 - } 277 - } 278 - } 279 - 280 - @OptIn(ExperimentalMaterial3Api::class) 281 - @Composable 282 - fun ActionRow( 283 - context: Context, 284 - uploadingPost: MutableState<Boolean>, 285 - pickMedia: ManagedActivityResultLauncher<PickVisualMediaRequest, List<@JvmSuppressWildcards Uri>>, 286 - postText: String, 287 - mediaSelected: MutableState<Map<Uri, String?>>, 288 - mediaSelectedIsVideo: MutableState<Boolean>, 289 - coroutineScope: CoroutineScope, 290 - maxChars: Int, 291 - timelineViewModel: TimelineViewModel, 292 - scaffoldState: BottomSheetScaffoldState, 293 - inReplyToData: SkeetData? = null 294 - ) { 295 - 296 - Row( 297 - modifier = Modifier 298 - .fillMaxWidth() 299 - .padding( 300 - vertical = 8.dp, 301 - ), // Internal padding for the button row 302 - horizontalArrangement = Arrangement.SpaceBetween, 303 - verticalAlignment = Alignment.CenterVertically 304 - ) { 305 - TextButton( 306 - onClick = { 307 - pickMedia.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageAndVideo)) 308 - } 309 - ) { 310 - Icon(Icons.Default.CameraRoll, contentDescription = "Attach media") 311 - } 312 - 313 - if (uploadingPost.value) { 314 - CircularProgressIndicator() 315 - } 316 - 317 - val postButtonEnabled = remember(postText, mediaSelected.value) { 318 - (postText.isNotBlank() || mediaSelected.value.isNotEmpty()) && postText.length <= maxChars 319 - } 320 - 321 - Button( 322 - onClick = { 323 - if (postText.isNotBlank() && postText.length <= maxChars) { 324 - coroutineScope.launch { 325 - uploadingPost.value = true // Show progress immediately 326 - timelineViewModel.post( 327 - content = postText, 328 - images = if (!mediaSelectedIsVideo.value) mediaSelected.value.keys.toList() 329 - .ifEmpty { null } else null, 330 - video = if (mediaSelectedIsVideo.value) mediaSelected.value.keys.firstOrNull() else null, 331 - replyRef = inReplyToData?.replyRef(), 332 - ).onSuccess { 333 - coroutineScope.launch { 334 - scaffoldState.bottomSheetState.hide() 335 - // State reset is now in LaunchedEffect for isVisible 336 - } 337 - }.onFailure { 338 - Toast.makeText( 339 - context, 340 - "Could not post: ${it.message}", 341 - Toast.LENGTH_LONG 342 - ).show() 343 - }.also { 344 - uploadingPost.value = false // Hide progress after completion 345 - } 346 - } 347 - } 348 - }, 349 - modifier = Modifier.padding(end = 8.dp), 350 - enabled = postButtonEnabled && !uploadingPost.value // Disable while uploading 351 - ) { 352 - Icon(Icons.AutoMirrored.Filled.Send, contentDescription = "Post") 353 - Spacer(Modifier.size(ButtonDefaults.IconSpacing)) 354 - Text("Skeet") 355 - } 356 - } 1 + package industries.geesawra.monarch 2 + 3 + import android.content.Context 4 + import android.net.Uri 5 + import android.widget.Toast 6 + import androidx.activity.compose.ManagedActivityResultLauncher 7 + import androidx.activity.compose.rememberLauncherForActivityResult 8 + import androidx.activity.result.PickVisualMediaRequest 9 + import androidx.activity.result.contract.ActivityResultContracts 10 + import androidx.compose.foundation.ExperimentalFoundationApi 11 + import androidx.compose.foundation.ScrollState 12 + import androidx.compose.foundation.background 13 + import androidx.compose.foundation.content.MediaType 14 + import androidx.compose.foundation.content.ReceiveContentListener 15 + import androidx.compose.foundation.content.consume 16 + import androidx.compose.foundation.content.contentReceiver 17 + import androidx.compose.foundation.content.hasMediaType 18 + import androidx.compose.foundation.layout.Arrangement 19 + import androidx.compose.foundation.layout.Box 20 + import androidx.compose.foundation.layout.Column 21 + import androidx.compose.foundation.layout.Row 22 + import androidx.compose.foundation.layout.Spacer 23 + import androidx.compose.foundation.layout.WindowInsets 24 + import androidx.compose.foundation.layout.add 25 + import androidx.compose.foundation.layout.fillMaxSize 26 + import androidx.compose.foundation.layout.fillMaxWidth 27 + import androidx.compose.foundation.layout.height 28 + import androidx.compose.foundation.layout.heightIn 29 + import androidx.compose.foundation.layout.ime 30 + import androidx.compose.foundation.layout.navigationBars 31 + import androidx.compose.foundation.layout.padding 32 + import androidx.compose.foundation.layout.size 33 + import androidx.compose.foundation.layout.windowInsetsPadding 34 + import androidx.compose.foundation.text.input.TextFieldLineLimits 35 + import androidx.compose.foundation.text.input.clearText 36 + import androidx.compose.foundation.text.input.rememberTextFieldState 37 + import androidx.compose.foundation.verticalScroll 38 + import androidx.compose.material.icons.Icons 39 + import androidx.compose.material.icons.automirrored.filled.Send 40 + import androidx.compose.material.icons.filled.CameraRoll 41 + import androidx.compose.material3.BottomSheetScaffoldState 42 + import androidx.compose.material3.Button 43 + import androidx.compose.material3.ButtonDefaults 44 + import androidx.compose.material3.Card 45 + import androidx.compose.material3.CircularProgressIndicator 46 + import androidx.compose.material3.ExperimentalMaterial3Api 47 + import androidx.compose.material3.Icon 48 + import androidx.compose.material3.MaterialTheme 49 + import androidx.compose.material3.OutlinedCard 50 + import androidx.compose.material3.OutlinedTextField 51 + import androidx.compose.material3.Text 52 + import androidx.compose.material3.TextButton 53 + import androidx.compose.runtime.Composable 54 + import androidx.compose.runtime.LaunchedEffect 55 + import androidx.compose.runtime.MutableState 56 + import androidx.compose.runtime.mutableIntStateOf 57 + import androidx.compose.runtime.mutableStateOf 58 + import androidx.compose.runtime.remember 59 + import androidx.compose.ui.Alignment 60 + import androidx.compose.ui.Modifier 61 + import androidx.compose.ui.focus.FocusRequester 62 + import androidx.compose.ui.focus.focusRequester 63 + import androidx.compose.ui.graphics.Color 64 + import androidx.compose.ui.platform.LocalSoftwareKeyboardController 65 + import androidx.compose.ui.unit.dp 66 + import industries.geesawra.monarch.datalayer.SkeetData 67 + import industries.geesawra.monarch.datalayer.TimelineViewModel 68 + import kotlinx.coroutines.CoroutineScope 69 + import kotlinx.coroutines.launch 70 + 71 + @OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) 72 + @Composable 73 + fun ComposeView( 74 + context: Context, 75 + coroutineScope: CoroutineScope, 76 + timelineViewModel: TimelineViewModel, 77 + inReplyTo: MutableState<SkeetData?>, 78 + scaffoldState: BottomSheetScaffoldState, 79 + scrollState: ScrollState 80 + ) { 81 + val focusRequester = remember { FocusRequester() } 82 + val keyboardController = LocalSoftwareKeyboardController.current 83 + val charCount = remember { mutableIntStateOf(0) } 84 + val wasEdited = remember { mutableStateOf(false) } 85 + val maxChars = 300 86 + val composeFieldState = rememberTextFieldState( 87 + "" 88 + ) 89 + val mediaSelected = remember { mutableStateOf(mapOf<Uri, String?>()) } 90 + val mediaSelectedIsVideo = remember { mutableStateOf(false) } 91 + 92 + LaunchedEffect(scaffoldState.bottomSheetState.isVisible) { 93 + if (scaffoldState.bottomSheetState.isVisible) { 94 + keyboardController?.show() 95 + focusRequester.requestFocus() 96 + } else { 97 + keyboardController?.hide() 98 + // Reset state when sheet is hidden 99 + composeFieldState.clearText() 100 + charCount.intValue = 0 101 + inReplyTo.value = null 102 + mediaSelected.value = mapOf() 103 + mediaSelectedIsVideo.value = false 104 + 105 + } 106 + } 107 + 108 + // Remember the ReceiveContentListener object as it is created inside a Composable scope 109 + val receiveContentListener = remember { 110 + ReceiveContentListener { transferableContent -> 111 + when (transferableContent.hasMediaType(MediaType.Image)) { 112 + true -> transferableContent.consume { 113 + val uri = it.uri 114 + val mimeType: String? = context.contentResolver.getType(uri) 115 + mediaSelected.value = mapOf(Pair(uri, mimeType)) 116 + true 117 + } 118 + 119 + 120 + false -> transferableContent 121 + } 122 + } 123 + } 124 + 125 + 126 + val pickMedia = 127 + rememberLauncherForActivityResult(ActivityResultContracts.PickMultipleVisualMedia(maxItems = 4)) { uris -> 128 + if (uris.isEmpty()) { 129 + return@rememberLauncherForActivityResult 130 + } 131 + 132 + val urisMap = uris.associateWith { uri -> 133 + val mimeType: String? = context.contentResolver.getType(uri) 134 + mimeType?.let { 135 + if (it.startsWith("image/")) { 136 + return@associateWith "image" 137 + } else if (it.startsWith("video/")) { 138 + return@associateWith "video" 139 + } 140 + } 141 + null 142 + } 143 + 144 + val containsVideo = urisMap.values.any { it == "video" } 145 + 146 + if (urisMap.size > 1 && containsVideo) { 147 + Toast.makeText( 148 + context, 149 + "Can only post up to 1 video or 4 pictures. Video cannot be mixed with other media.", 150 + Toast.LENGTH_LONG 151 + ).show() 152 + return@rememberLauncherForActivityResult 153 + } 154 + if (containsVideo && urisMap.any { it.value == "image" }) { 155 + Toast.makeText( 156 + context, 157 + "Video cannot be mixed with other media.", 158 + Toast.LENGTH_LONG 159 + ).show() 160 + return@rememberLauncherForActivityResult 161 + } 162 + 163 + mediaSelectedIsVideo.value = containsVideo && urisMap.size == 1 164 + mediaSelected.value = urisMap.filterValues { it != null } 165 + } 166 + 167 + val uploadingPost = remember { mutableStateOf(false) } 168 + 169 + // Outer Box: Handles IME padding and general content padding for the whole sheet content 170 + Box( 171 + modifier = Modifier 172 + .fillMaxWidth() // Takes full width of the bottom sheet 173 + .windowInsetsPadding(WindowInsets.ime.add(WindowInsets.navigationBars)) 174 + .verticalScroll(scrollState) 175 + .padding(16.dp) // General padding around all content inside the sheet 176 + ) { 177 + // Inner Box: Fills the space provided by Outer Box, used for layering scrollable content and fixed buttons 178 + Box(modifier = Modifier.fillMaxSize()) { 179 + // Scrollable Content Column 180 + Column( 181 + modifier = Modifier 182 + .fillMaxWidth(), // Takes full width of the Inner Box 183 + horizontalAlignment = Alignment.CenterHorizontally 184 + ) { 185 + Row { 186 + Text( 187 + "New Post", 188 + style = MaterialTheme.typography.titleLarge, 189 + modifier = Modifier.padding(bottom = 16.dp) 190 + ) 191 + } 192 + 193 + inReplyTo.value?.let { 194 + OutlinedCard( 195 + modifier = Modifier.padding(8.dp) 196 + ) { 197 + SkeetView( 198 + modifier = Modifier 199 + .padding(8.dp) 200 + .background(Color.Transparent), 201 + skeet = it, 202 + nested = true, 203 + disableEmbeds = true 204 + ) 205 + } 206 + } 207 + 208 + LaunchedEffect(composeFieldState.text) { 209 + if (composeFieldState.text.isEmpty()) { 210 + wasEdited.value = false 211 + } else { 212 + wasEdited.value = true 213 + charCount.intValue = composeFieldState.text.length 214 + } 215 + } 216 + 217 + OutlinedTextField( 218 + modifier = Modifier 219 + .fillMaxWidth() 220 + .heightIn(min = 250.dp) 221 + .focusRequester(focusRequester) 222 + .contentReceiver(receiveContentListener), 223 + label = { 224 + if (wasEdited.value) { 225 + Text( 226 + text = "${maxChars - charCount.intValue}", 227 + color = if (composeFieldState.text.length > maxChars) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurface 228 + ) 229 + } else { 230 + Text( 231 + text = "Less cringe this time, okay?", 232 + ) 233 + } 234 + }, 235 + isError = composeFieldState.text.length > maxChars, 236 + lineLimits = TextFieldLineLimits.MultiLine(maxHeightInLines = 10), 237 + state = composeFieldState 238 + ) 239 + 240 + ActionRow( 241 + context, 242 + uploadingPost, 243 + pickMedia, 244 + composeFieldState.text.toString(), 245 + mediaSelected, 246 + mediaSelectedIsVideo, 247 + coroutineScope, 248 + maxChars, 249 + timelineViewModel, 250 + scaffoldState, 251 + inReplyTo.value 252 + ) 253 + 254 + Spacer(modifier = Modifier.height(8.dp)) 255 + 256 + if (mediaSelected.value.isNotEmpty()) { 257 + Card( 258 + modifier = Modifier 259 + .fillMaxWidth() 260 + .padding(vertical = 8.dp) 261 + ) { 262 + PostImageGallery( 263 + modifier = Modifier 264 + .fillMaxWidth() // Gallery should fill card width 265 + .padding(8.dp), 266 + images = mediaSelected.value.keys.map { uri -> 267 + Image(url = uri.toString(), alt = "Selected media") 268 + }, 269 + ) 270 + } 271 + } 272 + 273 + // Spacer at the end of scrollable content to prevent overlap with fixed buttons 274 + Spacer(modifier = Modifier.height(80.dp)) 275 + } 276 + } 277 + } 278 + } 279 + 280 + @OptIn(ExperimentalMaterial3Api::class) 281 + @Composable 282 + fun ActionRow( 283 + context: Context, 284 + uploadingPost: MutableState<Boolean>, 285 + pickMedia: ManagedActivityResultLauncher<PickVisualMediaRequest, List<@JvmSuppressWildcards Uri>>, 286 + postText: String, 287 + mediaSelected: MutableState<Map<Uri, String?>>, 288 + mediaSelectedIsVideo: MutableState<Boolean>, 289 + coroutineScope: CoroutineScope, 290 + maxChars: Int, 291 + timelineViewModel: TimelineViewModel, 292 + scaffoldState: BottomSheetScaffoldState, 293 + inReplyToData: SkeetData? = null 294 + ) { 295 + 296 + Row( 297 + modifier = Modifier 298 + .fillMaxWidth() 299 + .padding( 300 + vertical = 8.dp, 301 + ), // Internal padding for the button row 302 + horizontalArrangement = Arrangement.SpaceBetween, 303 + verticalAlignment = Alignment.CenterVertically 304 + ) { 305 + TextButton( 306 + onClick = { 307 + pickMedia.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageAndVideo)) 308 + } 309 + ) { 310 + Icon(Icons.Default.CameraRoll, contentDescription = "Attach media") 311 + } 312 + 313 + if (uploadingPost.value) { 314 + CircularProgressIndicator() 315 + } 316 + 317 + val postButtonEnabled = remember(postText, mediaSelected.value) { 318 + (postText.isNotBlank() || mediaSelected.value.isNotEmpty()) && postText.length <= maxChars 319 + } 320 + 321 + Button( 322 + onClick = { 323 + if (postText.isNotBlank() && postText.length <= maxChars) { 324 + coroutineScope.launch { 325 + uploadingPost.value = true // Show progress immediately 326 + timelineViewModel.post( 327 + content = postText, 328 + images = if (!mediaSelectedIsVideo.value) mediaSelected.value.keys.toList() 329 + .ifEmpty { null } else null, 330 + video = if (mediaSelectedIsVideo.value) mediaSelected.value.keys.firstOrNull() else null, 331 + replyRef = inReplyToData?.replyRef(), 332 + ).onSuccess { 333 + coroutineScope.launch { 334 + scaffoldState.bottomSheetState.hide() 335 + // State reset is now in LaunchedEffect for isVisible 336 + } 337 + }.onFailure { 338 + Toast.makeText( 339 + context, 340 + "Could not post: ${it.message}", 341 + Toast.LENGTH_LONG 342 + ).show() 343 + }.also { 344 + uploadingPost.value = false // Hide progress after completion 345 + } 346 + } 347 + } 348 + }, 349 + modifier = Modifier.padding(end = 8.dp), 350 + enabled = postButtonEnabled && !uploadingPost.value // Disable while uploading 351 + ) { 352 + Icon(Icons.AutoMirrored.Filled.Send, contentDescription = "Post") 353 + Spacer(Modifier.size(ButtonDefaults.IconSpacing)) 354 + Text("Skeet") 355 + } 356 + } 357 357 }
+1 -1
app/src/main/java/industries/geesawra/jerryno/GalleryViewer.kt app/src/main/java/industries/geesawra/monarch/GalleryViewer.kt
··· 1 - package industries.geesawra.jerryno 1 + package industries.geesawra.monarch 2 2 3 3 import android.content.Context 4 4 import android.widget.Toast
+2 -2
app/src/main/java/industries/geesawra/jerryno/LoginView.kt app/src/main/java/industries/geesawra/monarch/LoginView.kt
··· 1 - package industries.geesawra.jerryno 1 + package industries.geesawra.monarch 2 2 3 3 import android.util.Log 4 4 import android.widget.Toast ··· 34 34 import androidx.compose.ui.text.input.PasswordVisualTransformation 35 35 import androidx.compose.ui.text.input.VisualTransformation 36 36 import androidx.compose.ui.unit.dp 37 - import industries.geesawra.jerryno.datalayer.BlueskyConn 37 + import industries.geesawra.monarch.datalayer.BlueskyConn 38 38 import kotlinx.coroutines.CoroutineScope 39 39 import kotlinx.coroutines.delay 40 40 import kotlinx.coroutines.launch
+5 -5
app/src/main/java/industries/geesawra/jerryno/MainActivity.kt app/src/main/java/industries/geesawra/monarch/MainActivity.kt
··· 1 - package industries.geesawra.jerryno 1 + package industries.geesawra.monarch 2 2 3 3 import android.app.Application 4 4 import android.os.Bundle ··· 32 32 import coil3.network.okhttp.OkHttpNetworkFetcherFactory 33 33 import dagger.hilt.android.AndroidEntryPoint 34 34 import dagger.hilt.android.HiltAndroidApp 35 - import industries.geesawra.jerryno.datalayer.BlueskyConn 36 - import industries.geesawra.jerryno.datalayer.TimelineViewModel 37 - import industries.geesawra.jerryno.ui.theme.JerryNoTheme 35 + import industries.geesawra.monarch.datalayer.BlueskyConn 36 + import industries.geesawra.monarch.datalayer.TimelineViewModel 37 + import industries.geesawra.monarch.ui.theme.MonarchTheme 38 38 39 39 40 40 @HiltAndroidApp ··· 55 55 56 56 57 57 setContent { 58 - JerryNoTheme { 58 + MonarchTheme { 59 59 val context = LocalContext.current 60 60 SingletonImageLoader.setSafe { 61 61 ImageLoader.Builder(context)
+1 -1
app/src/main/java/industries/geesawra/jerryno/PostImageGallery.kt app/src/main/java/industries/geesawra/monarch/PostImageGallery.kt
··· 1 - package industries.geesawra.jerryno 1 + package industries.geesawra.monarch 2 2 3 3 import androidx.compose.foundation.clickable 4 4 import androidx.compose.foundation.layout.Arrangement
+3 -3
app/src/main/java/industries/geesawra/jerryno/ShowSkeets.kt app/src/main/java/industries/geesawra/monarch/ShowSkeets.kt
··· 1 - package industries.geesawra.jerryno 1 + package industries.geesawra.monarch 2 2 3 3 import androidx.compose.foundation.layout.Arrangement 4 4 import androidx.compose.foundation.layout.Box ··· 18 18 import androidx.compose.ui.Alignment 19 19 import androidx.compose.ui.Modifier 20 20 import androidx.compose.ui.unit.dp 21 - import industries.geesawra.jerryno.datalayer.SkeetData 22 - import industries.geesawra.jerryno.datalayer.TimelineViewModel 21 + import industries.geesawra.monarch.datalayer.SkeetData 22 + import industries.geesawra.monarch.datalayer.TimelineViewModel 23 23 24 24 @Composable 25 25 fun ShowSkeets(
+3 -3
app/src/main/java/industries/geesawra/jerryno/SkeetView.kt app/src/main/java/industries/geesawra/monarch/SkeetView.kt
··· 1 - package industries.geesawra.jerryno 1 + package industries.geesawra.monarch 2 2 3 3 import androidx.browser.customtabs.CustomTabsIntent 4 4 import androidx.compose.foundation.clickable ··· 38 38 import coil3.compose.AsyncImage 39 39 import coil3.request.ImageRequest 40 40 import coil3.request.crossfade 41 - import industries.geesawra.jerryno.datalayer.SkeetData 42 - import industries.geesawra.jerryno.datalayer.TimelineViewModel 41 + import industries.geesawra.monarch.datalayer.SkeetData 42 + import industries.geesawra.monarch.datalayer.TimelineViewModel 43 43 import io.sanghun.compose.video.RepeatMode 44 44 import io.sanghun.compose.video.VideoPlayer 45 45 import io.sanghun.compose.video.controller.VideoPlayerControllerConfig
+189 -189
app/src/main/java/industries/geesawra/jerryno/TimelinePostActionsView.kt app/src/main/java/industries/geesawra/monarch/TimelinePostActionsView.kt
··· 1 - package industries.geesawra.jerryno 2 - 3 - import android.content.Intent 4 - import androidx.compose.foundation.layout.Arrangement 5 - import androidx.compose.foundation.layout.Row 6 - import androidx.compose.foundation.layout.padding 7 - import androidx.compose.foundation.layout.size 8 - import androidx.compose.material.icons.Icons 9 - import androidx.compose.material.icons.automirrored.filled.Reply 10 - import androidx.compose.material.icons.automirrored.filled.ReplyAll 11 - import androidx.compose.material.icons.filled.Repeat 12 - import androidx.compose.material.icons.filled.RepeatOn 13 - import androidx.compose.material.icons.filled.Share 14 - import androidx.compose.material.icons.filled.ThumbUp 15 - import androidx.compose.material.icons.filled.ThumbUpOffAlt 16 - import androidx.compose.material3.Icon 17 - import androidx.compose.material3.IconButton 18 - import androidx.compose.material3.MaterialTheme 19 - import androidx.compose.material3.Text 20 - import androidx.compose.runtime.Composable 21 - import androidx.compose.runtime.MutableLongState 22 - import androidx.compose.runtime.getValue 23 - import androidx.compose.runtime.mutableLongStateOf 24 - import androidx.compose.runtime.mutableStateOf 25 - import androidx.compose.runtime.remember 26 - import androidx.compose.runtime.saveable.rememberSaveable 27 - import androidx.compose.runtime.setValue 28 - import androidx.compose.ui.Alignment 29 - import androidx.compose.ui.Modifier 30 - import androidx.compose.ui.graphics.Color 31 - import androidx.compose.ui.graphics.vector.ImageVector 32 - import androidx.compose.ui.platform.LocalContext 33 - import androidx.compose.ui.unit.dp 34 - import androidx.core.net.toUri 35 - import industries.geesawra.jerryno.datalayer.SkeetData 36 - import industries.geesawra.jerryno.datalayer.TimelineViewModel 37 - import sh.christian.ozone.api.AtUri 38 - import sh.christian.ozone.api.RKey 39 - 40 - 41 - @Composable 42 - private fun IconWithNumber( 43 - imageVector: ImageVector, 44 - contentDescription: String, 45 - number: MutableLongState, 46 - tint: Color 47 - ) { 48 - Row( 49 - horizontalArrangement = Arrangement.Center, 50 - verticalAlignment = Alignment.CenterVertically 51 - ) { 52 - var fontSize by remember { 53 - mutableStateOf(10.dp) 54 - } 55 - Icon( 56 - imageVector, 57 - contentDescription = contentDescription, 58 - modifier = Modifier.size(15.dp), 59 - tint = tint 60 - ) 61 - Text( 62 - modifier = Modifier.padding(start = 2.dp), 63 - text = number.longValue.toString(), 64 - color = tint, 65 - maxLines = 1, 66 - onTextLayout = { textLayout -> 67 - if (textLayout.multiParagraph.didExceedMaxLines) { 68 - fontSize -= 1.dp 69 - } 70 - } 71 - ) 72 - } 73 - } 74 - 75 - fun AtUri.rkey(): RKey { 76 - return RKey(this.atUri.toUri().lastPathSegment!!) 77 - } 78 - 79 - @Composable 80 - fun TimelinePostActionsView( 81 - modifier: Modifier = Modifier, 82 - timelineViewModel: TimelineViewModel?, 83 - onReplyTap: (SkeetData) -> Unit = {}, 84 - skeet: SkeetData 85 - ) { 86 - val likes = remember { mutableLongStateOf(skeet.likes ?: 0) } 87 - val reposts = remember { mutableLongStateOf(skeet.reposts ?: 0) } 88 - val replies = remember { mutableLongStateOf(skeet.replies ?: 0) } 89 - 90 - 91 - Row( 92 - horizontalArrangement = Arrangement.End, 93 - modifier = modifier, 94 - ) { 95 - val ctx = LocalContext.current 96 - IconButton( 97 - onClick = { 98 - val sendIntent: Intent = Intent().apply { 99 - action = Intent.ACTION_SEND 100 - type = "text/plain" 101 - putExtra(Intent.EXTRA_TEXT, skeet.shareURL()) 102 - } 103 - ctx.startActivity( 104 - Intent.createChooser(sendIntent, "Share Bluesky post") 105 - ) 106 - } 107 - ) { 108 - Icon( 109 - modifier = Modifier.size(15.dp), 110 - imageVector = Icons.Default.Share, 111 - contentDescription = "Share", 112 - tint = MaterialTheme.colorScheme.onSurfaceVariant 113 - ) 114 - } 115 - 116 - IconButton( 117 - onClick = { 118 - onReplyTap(skeet) 119 - } 120 - ) { 121 - IconWithNumber( 122 - imageVector = { 123 - if (replies.longValue == 0.toLong()) { 124 - Icons.AutoMirrored.Filled.Reply 125 - } 126 - 127 - val r = replies 128 - if (r.longValue > 0) { 129 - Icons.AutoMirrored.Filled.ReplyAll 130 - } else { 131 - Icons.AutoMirrored.Filled.Reply 132 - } 133 - 134 - }(), 135 - contentDescription = "Reply", 136 - number = replies, 137 - MaterialTheme.colorScheme.onSurfaceVariant 138 - ) 139 - } 140 - 141 - var isLiked by rememberSaveable { mutableStateOf(skeet.didLike) } 142 - IconButton( 143 - onClick = { 144 - when (isLiked) { 145 - false -> timelineViewModel?.like(skeet.uri, skeet.cid) { 146 - isLiked = true 147 - likes.longValue++ 148 - } 149 - 150 - true -> timelineViewModel?.deleteLike(skeet.cid) { 151 - isLiked = false 152 - likes.longValue-- 153 - } 154 - } 155 - } 156 - ) { 157 - IconWithNumber( 158 - if (isLiked) Icons.Default.ThumbUp else Icons.Default.ThumbUpOffAlt, 159 - contentDescription = "Like", 160 - number = likes, 161 - tint = if (isLiked) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant 162 - ) 163 - } 164 - 165 - var isReposted by rememberSaveable { mutableStateOf(skeet.didRepost) } 166 - IconButton( 167 - onClick = { 168 - when (isReposted) { 169 - false -> timelineViewModel?.repost(skeet.uri, skeet.cid) { 170 - isReposted = true 171 - reposts.longValue++ 172 - } 173 - 174 - true -> timelineViewModel?.deleteRepost(skeet.cid) { 175 - isReposted = false 176 - reposts.longValue-- 177 - } 178 - } 179 - 180 - } 181 - ) { 182 - IconWithNumber( 183 - if (isReposted) Icons.Default.RepeatOn else Icons.Default.Repeat, 184 - contentDescription = "Repost", 185 - number = reposts, 186 - if (isLiked) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant 187 - ) 188 - } 189 - } 1 + package industries.geesawra.monarch 2 + 3 + import android.content.Intent 4 + import androidx.compose.foundation.layout.Arrangement 5 + import androidx.compose.foundation.layout.Row 6 + import androidx.compose.foundation.layout.padding 7 + import androidx.compose.foundation.layout.size 8 + import androidx.compose.material.icons.Icons 9 + import androidx.compose.material.icons.automirrored.filled.Reply 10 + import androidx.compose.material.icons.automirrored.filled.ReplyAll 11 + import androidx.compose.material.icons.filled.Repeat 12 + import androidx.compose.material.icons.filled.RepeatOn 13 + import androidx.compose.material.icons.filled.Share 14 + import androidx.compose.material.icons.filled.ThumbUp 15 + import androidx.compose.material.icons.filled.ThumbUpOffAlt 16 + import androidx.compose.material3.Icon 17 + import androidx.compose.material3.IconButton 18 + import androidx.compose.material3.MaterialTheme 19 + import androidx.compose.material3.Text 20 + import androidx.compose.runtime.Composable 21 + import androidx.compose.runtime.MutableLongState 22 + import androidx.compose.runtime.getValue 23 + import androidx.compose.runtime.mutableLongStateOf 24 + import androidx.compose.runtime.mutableStateOf 25 + import androidx.compose.runtime.remember 26 + import androidx.compose.runtime.saveable.rememberSaveable 27 + import androidx.compose.runtime.setValue 28 + import androidx.compose.ui.Alignment 29 + import androidx.compose.ui.Modifier 30 + import androidx.compose.ui.graphics.Color 31 + import androidx.compose.ui.graphics.vector.ImageVector 32 + import androidx.compose.ui.platform.LocalContext 33 + import androidx.compose.ui.unit.dp 34 + import androidx.core.net.toUri 35 + import industries.geesawra.monarch.datalayer.SkeetData 36 + import industries.geesawra.monarch.datalayer.TimelineViewModel 37 + import sh.christian.ozone.api.AtUri 38 + import sh.christian.ozone.api.RKey 39 + 40 + 41 + @Composable 42 + private fun IconWithNumber( 43 + imageVector: ImageVector, 44 + contentDescription: String, 45 + number: MutableLongState, 46 + tint: Color 47 + ) { 48 + Row( 49 + horizontalArrangement = Arrangement.Center, 50 + verticalAlignment = Alignment.CenterVertically 51 + ) { 52 + var fontSize by remember { 53 + mutableStateOf(10.dp) 54 + } 55 + Icon( 56 + imageVector, 57 + contentDescription = contentDescription, 58 + modifier = Modifier.size(15.dp), 59 + tint = tint 60 + ) 61 + Text( 62 + modifier = Modifier.padding(start = 2.dp), 63 + text = number.longValue.toString(), 64 + color = tint, 65 + maxLines = 1, 66 + onTextLayout = { textLayout -> 67 + if (textLayout.multiParagraph.didExceedMaxLines) { 68 + fontSize -= 1.dp 69 + } 70 + } 71 + ) 72 + } 73 + } 74 + 75 + fun AtUri.rkey(): RKey { 76 + return RKey(this.atUri.toUri().lastPathSegment!!) 77 + } 78 + 79 + @Composable 80 + fun TimelinePostActionsView( 81 + modifier: Modifier = Modifier, 82 + timelineViewModel: TimelineViewModel?, 83 + onReplyTap: (SkeetData) -> Unit = {}, 84 + skeet: SkeetData 85 + ) { 86 + val likes = remember { mutableLongStateOf(skeet.likes ?: 0) } 87 + val reposts = remember { mutableLongStateOf(skeet.reposts ?: 0) } 88 + val replies = remember { mutableLongStateOf(skeet.replies ?: 0) } 89 + 90 + 91 + Row( 92 + horizontalArrangement = Arrangement.End, 93 + modifier = modifier, 94 + ) { 95 + val ctx = LocalContext.current 96 + IconButton( 97 + onClick = { 98 + val sendIntent: Intent = Intent().apply { 99 + action = Intent.ACTION_SEND 100 + type = "text/plain" 101 + putExtra(Intent.EXTRA_TEXT, skeet.shareURL()) 102 + } 103 + ctx.startActivity( 104 + Intent.createChooser(sendIntent, "Share Bluesky post") 105 + ) 106 + } 107 + ) { 108 + Icon( 109 + modifier = Modifier.size(15.dp), 110 + imageVector = Icons.Default.Share, 111 + contentDescription = "Share", 112 + tint = MaterialTheme.colorScheme.onSurfaceVariant 113 + ) 114 + } 115 + 116 + IconButton( 117 + onClick = { 118 + onReplyTap(skeet) 119 + } 120 + ) { 121 + IconWithNumber( 122 + imageVector = { 123 + if (replies.longValue == 0.toLong()) { 124 + Icons.AutoMirrored.Filled.Reply 125 + } 126 + 127 + val r = replies 128 + if (r.longValue > 0) { 129 + Icons.AutoMirrored.Filled.ReplyAll 130 + } else { 131 + Icons.AutoMirrored.Filled.Reply 132 + } 133 + 134 + }(), 135 + contentDescription = "Reply", 136 + number = replies, 137 + MaterialTheme.colorScheme.onSurfaceVariant 138 + ) 139 + } 140 + 141 + var isLiked by rememberSaveable { mutableStateOf(skeet.didLike) } 142 + IconButton( 143 + onClick = { 144 + when (isLiked) { 145 + false -> timelineViewModel?.like(skeet.uri, skeet.cid) { 146 + isLiked = true 147 + likes.longValue++ 148 + } 149 + 150 + true -> timelineViewModel?.deleteLike(skeet.cid) { 151 + isLiked = false 152 + likes.longValue-- 153 + } 154 + } 155 + } 156 + ) { 157 + IconWithNumber( 158 + if (isLiked) Icons.Default.ThumbUp else Icons.Default.ThumbUpOffAlt, 159 + contentDescription = "Like", 160 + number = likes, 161 + tint = if (isLiked) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant 162 + ) 163 + } 164 + 165 + var isReposted by rememberSaveable { mutableStateOf(skeet.didRepost) } 166 + IconButton( 167 + onClick = { 168 + when (isReposted) { 169 + false -> timelineViewModel?.repost(skeet.uri, skeet.cid) { 170 + isReposted = true 171 + reposts.longValue++ 172 + } 173 + 174 + true -> timelineViewModel?.deleteRepost(skeet.cid) { 175 + isReposted = false 176 + reposts.longValue-- 177 + } 178 + } 179 + 180 + } 181 + ) { 182 + IconWithNumber( 183 + if (isReposted) Icons.Default.RepeatOn else Icons.Default.Repeat, 184 + contentDescription = "Repost", 185 + number = reposts, 186 + if (isLiked) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant 187 + ) 188 + } 189 + } 190 190 }
+3 -3
app/src/main/java/industries/geesawra/jerryno/TimelineView.kt app/src/main/java/industries/geesawra/monarch/TimelineView.kt
··· 1 - package industries.geesawra.jerryno 1 + package industries.geesawra.monarch 2 2 3 3 // import androidx.compose.foundation.layout.height // Will be removed for the sheet content Box 4 4 import android.widget.Toast ··· 63 63 import androidx.compose.ui.text.font.FontWeight 64 64 import androidx.compose.ui.unit.dp 65 65 import coil3.compose.AsyncImage 66 - import industries.geesawra.jerryno.datalayer.SkeetData 67 - import industries.geesawra.jerryno.datalayer.TimelineViewModel 66 + import industries.geesawra.monarch.datalayer.SkeetData 67 + import industries.geesawra.monarch.datalayer.TimelineViewModel 68 68 import kotlinx.coroutines.CoroutineScope 69 69 import kotlinx.coroutines.launch 70 70
+639 -639
app/src/main/java/industries/geesawra/jerryno/datalayer/Bluesky.kt app/src/main/java/industries/geesawra/monarch/datalayer/Bluesky.kt
··· 1 - package industries.geesawra.jerryno.datalayer 2 - 3 - import android.content.Context 4 - import android.net.Uri 5 - import android.util.Log 6 - import androidx.compose.ui.text.intl.Locale 7 - import androidx.compose.ui.text.toLowerCase 8 - import androidx.core.net.toUri 9 - import androidx.datastore.preferences.core.edit 10 - import androidx.datastore.preferences.core.stringPreferencesKey 11 - import androidx.datastore.preferences.preferencesDataStore 12 - import app.bsky.actor.PreferencesUnion 13 - import app.bsky.embed.Images 14 - import app.bsky.embed.ImagesImage 15 - import app.bsky.embed.Video 16 - import app.bsky.feed.GeneratorView 17 - import app.bsky.feed.GetFeedGeneratorsQueryParams 18 - import app.bsky.feed.GetTimelineQueryParams 19 - import app.bsky.feed.GetTimelineResponse 20 - import app.bsky.feed.Like 21 - import app.bsky.feed.Post 22 - import app.bsky.feed.PostEmbedUnion 23 - import app.bsky.feed.PostReplyRef 24 - import app.bsky.feed.Repost 25 - import com.atproto.identity.ResolveHandleQueryParams 26 - import com.atproto.identity.ResolveHandleResponse 27 - import com.atproto.repo.CreateRecordRequest 28 - import com.atproto.repo.CreateRecordResponse 29 - import com.atproto.repo.DeleteRecordRequest 30 - import com.atproto.repo.StrongRef 31 - import com.atproto.repo.UploadBlobResponse 32 - import com.atproto.server.CreateSessionRequest 33 - import com.atproto.server.CreateSessionResponse 34 - import com.atproto.server.RefreshSessionResponse 35 - import industries.geesawra.jerryno.rkey 36 - import io.ktor.client.HttpClient 37 - import io.ktor.client.call.body 38 - import io.ktor.client.engine.okhttp.OkHttp 39 - import io.ktor.client.plugins.HttpTimeout 40 - import io.ktor.client.plugins.defaultRequest 41 - import io.ktor.client.request.get 42 - import io.ktor.client.request.post 43 - import io.ktor.http.HttpStatusCode 44 - import io.ktor.http.URLProtocol 45 - import io.ktor.http.path 46 - import kotlinx.coroutines.flow.Flow 47 - import kotlinx.coroutines.flow.first 48 - import kotlinx.coroutines.flow.map 49 - import kotlinx.coroutines.sync.Mutex 50 - import kotlinx.datetime.Clock 51 - import kotlinx.serialization.Serializable 52 - import sh.christian.ozone.BlueskyJson 53 - import sh.christian.ozone.XrpcBlueskyApi 54 - import sh.christian.ozone.api.AtUri 55 - import sh.christian.ozone.api.AuthenticatedXrpcBlueskyApi 56 - import sh.christian.ozone.api.BlueskyAuthPlugin 57 - import sh.christian.ozone.api.Cid 58 - import sh.christian.ozone.api.Did 59 - import sh.christian.ozone.api.Handle 60 - import sh.christian.ozone.api.Nsid 61 - import sh.christian.ozone.api.RKey 62 - import sh.christian.ozone.api.model.Blob 63 - import sh.christian.ozone.api.model.JsonContent.Companion.encodeAsJsonContent 64 - import sh.christian.ozone.api.response.AtpResponse 65 - 66 - enum class AuthData { 67 - PDSHost, 68 - SessionData, 69 - } 70 - 71 - class LoginException(message: String?) : Exception(message) 72 - 73 - @Serializable // Added annotation 74 - data class SessionData( 75 - val accessJwt: String, 76 - val refreshJwt: String, 77 - val handle: Handle, 78 - val did: Did, 79 - val active: Boolean? = null, 80 - ) { 81 - fun encodeToJson(): String { 82 - return BlueskyJson.encodeToString(serializer(), this) 83 - } 84 - 85 - companion object { 86 - fun decodeFromJson(jsonString: String): SessionData { 87 - return BlueskyJson.decodeFromString(serializer(), jsonString) 88 - } 89 - 90 - fun fromCreateSessionResponse(session: CreateSessionResponse): SessionData { 91 - return SessionData( 92 - accessJwt = session.accessJwt, 93 - refreshJwt = session.refreshJwt, 94 - handle = session.handle, 95 - did = session.did, 96 - active = session.active, 97 - ) 98 - } 99 - 100 - fun fromRefreshSessionResponse(session: RefreshSessionResponse): SessionData { 101 - return SessionData( 102 - accessJwt = session.accessJwt, 103 - refreshJwt = session.refreshJwt, 104 - handle = session.handle, 105 - did = session.did, 106 - active = session.active, 107 - ) 108 - } 109 - } 110 - } 111 - 112 - 113 - class BlueskyConn(val context: Context) { 114 - companion object { 115 - private val Context.dataStore by preferencesDataStore("bluesky") 116 - private val SESSION = stringPreferencesKey(AuthData.SessionData.name) 117 - private val PDSHOST = stringPreferencesKey(AuthData.PDSHost.name) 118 - 119 - suspend fun pdsForHandle(handle: String): Result<String> { 120 - return runCatching { 121 - val api = XrpcBlueskyApi() 122 - 123 - val rawId = api.resolveHandle( 124 - ResolveHandleQueryParams( 125 - handle = Handle(handle) 126 - ) 127 - ) 128 - 129 - val did = when (rawId) { 130 - is AtpResponse.Failure<*> -> { 131 - return Result.failure(Exception("Failed to resolve handle: ${rawId.error?.message}")) 132 - } 133 - 134 - is AtpResponse.Success<ResolveHandleResponse> -> { 135 - rawId.response.did.did 136 - } 137 - } 138 - 139 - val httpClient = HttpClient(OkHttp) { 140 - install(HttpTimeout) { 141 - requestTimeoutMillis = 15000 142 - connectTimeoutMillis = 15000 143 - socketTimeoutMillis = 15000 144 - } 145 - } 146 - 147 - val rawDoc = httpClient.get { 148 - url { 149 - protocol = URLProtocol.HTTPS 150 - host = "plc.directory" 151 - path(did) 152 - } 153 - } 154 - 155 - if (rawDoc.status != HttpStatusCode.OK) { 156 - return Result.failure(Exception("PLC lookup HTTP status code ${rawDoc.status}")) 157 - } 158 - 159 - val body: String = rawDoc.body() 160 - 161 - val solvedDoc: didDoc = BlueskyJson.decodeFromString(didDoc.serializer(), body) 162 - 163 - for (ps in solvedDoc.service) { 164 - if (ps.id == "#atproto_pds" && ps.type == "AtprotoPersonalDataServer") { 165 - return Result.success(ps.serviceEndpoint) 166 - } 167 - } 168 - 169 - return Result.failure(Exception("No PDS service defined in the DID document associated with ${handle}")) 170 - } 171 - } 172 - } 173 - 174 - @Serializable 175 - private data class service( 176 - val id: String, 177 - val type: String, 178 - val serviceEndpoint: String 179 - ) 180 - 181 - @Serializable 182 - private data class didDoc( 183 - val service: List<service> 184 - ) 185 - 186 - var client: AuthenticatedXrpcBlueskyApi? = null 187 - var session: SessionData? = null 188 - var createMutex: Mutex = Mutex() 189 - 190 - suspend fun storeSessionData(pdsURL: String, session: SessionData) { 191 - context.dataStore.edit { settings -> 192 - settings[SESSION] = session.encodeToJson() 193 - settings[PDSHOST] = pdsURL 194 - } 195 - } 196 - 197 - suspend fun cleanSessionData() { 198 - context.dataStore.edit { settings -> 199 - settings.remove(SESSION) 200 - settings.remove(PDSHOST) 201 - } 202 - } 203 - 204 - suspend fun hasSession(): Boolean { 205 - val pdsURLFlow: Flow<String> = context.dataStore.data.map { settings -> 206 - settings[PDSHOST] ?: "" 207 - } 208 - val sessionDataStringFlow: Flow<String> = context.dataStore.data.map { settings -> 209 - settings[SESSION] ?: "" 210 - } 211 - 212 - val pdsURL = pdsURLFlow.first() 213 - val sessionDataString = sessionDataStringFlow.first() 214 - 215 - return !(pdsURL.isEmpty() || sessionDataString.isEmpty()) 216 - } 217 - 218 - suspend fun login(pdsURL: String, handle: String, password: String): Result<Unit> { 219 - createMutex.lock() 220 - val httpClient = HttpClient(OkHttp) { 221 - defaultRequest { 222 - url(pdsURL) 223 - } 224 - install(HttpTimeout) { 225 - requestTimeoutMillis = 15000 226 - connectTimeoutMillis = 15000 227 - socketTimeoutMillis = 15000 228 - } 229 - } 230 - 231 - val client = XrpcBlueskyApi(httpClient) 232 - 233 - val s = client.createSession(CreateSessionRequest(handle, password)) 234 - val sessionResponse: CreateSessionResponse = when (s) { 235 - is AtpResponse.Failure<*> -> { 236 - createMutex.unlock() 237 - return Result.failure( 238 - Exception( 239 - "Failed to create session: ${ 240 - s.error?.message?.toLowerCase( 241 - Locale.current 242 - ) 243 - }" 244 - ) 245 - ) 246 - } 247 - 248 - is AtpResponse.Success<CreateSessionResponse> -> s.response 249 - } 250 - 251 - storeSessionData(pdsURL, SessionData.fromCreateSessionResponse(sessionResponse)) 252 - session = null 253 - this.client = null 254 - 255 - createMutex.unlock() 256 - return Result.success(Unit) 257 - } 258 - 259 - @Serializable 260 - private data class atpError( 261 - val error: String?, 262 - val message: String?, 263 - ) 264 - 265 - private suspend fun refreshIfNeeded(pdsURL: String, token: SessionData): Result<Unit> { 266 - return runCatching { 267 - val httpClient = HttpClient(OkHttp) { 268 - defaultRequest { 269 - url(pdsURL) 270 - } 271 - install(HttpTimeout) { 272 - requestTimeoutMillis = 15000 273 - connectTimeoutMillis = 15000 274 - socketTimeoutMillis = 15000 275 - } 276 - } 277 - 278 - val gs = httpClient.get { 279 - headers["Authorization"] = "Bearer " + token.accessJwt 280 - url { 281 - protocol = URLProtocol.HTTPS 282 - path("/xrpc/com.atproto.server.getSession") 283 - } 284 - } 285 - 286 - when (gs.status) { 287 - 288 - HttpStatusCode.OK -> run { 289 - this.session = token 290 - val tokens = 291 - BlueskyAuthPlugin.Tokens(token.accessJwt, token.refreshJwt) 292 - this.client = AuthenticatedXrpcBlueskyApi(httpClient, tokens) 293 - return Result.success(Unit) 294 - } 295 - 296 - else -> run { 297 - val body: String = gs.body() 298 - 299 - val error: atpError = 300 - BlueskyJson.decodeFromString( 301 - atpError.serializer(), 302 - body 303 - ) 304 - if (error.error == "ExpiredToken") { 305 - return@run 306 - } 307 - cleanSessionData() 308 - return Result.failure(Exception("Session checking failed, status code ${gs.status}: ${error.message}")) 309 - } 310 - } 311 - 312 - val rs = httpClient.post { 313 - headers["Authorization"] = "Bearer " + token.refreshJwt 314 - url { 315 - protocol = URLProtocol.HTTPS 316 - path("/xrpc/com.atproto.server.refreshSession") 317 - } 318 - } 319 - 320 - when (rs.status) { 321 - 322 - HttpStatusCode.OK -> run { 323 - val body: String = rs.body() 324 - val rs: RefreshSessionResponse = 325 - BlueskyJson.decodeFromString( 326 - RefreshSessionResponse.serializer(), 327 - body 328 - ) 329 - 330 - this.session = SessionData.fromRefreshSessionResponse(rs) 331 - storeSessionData(pdsURL, this.session!!) 332 - return Result.success(Unit) 333 - } 334 - 335 - else -> run { 336 - val body: String = rs.body() 337 - 338 - val error: atpError = 339 - BlueskyJson.decodeFromString( 340 - atpError.serializer(), 341 - body 342 - ) 343 - cleanSessionData() 344 - return Result.failure(Exception("Login refresh failed, status code ${rs.status}: ${error.message}")) 345 - } 346 - } 347 - 348 - } 349 - } 350 - 351 - suspend fun create(): Result<Unit> { 352 - return runCatching { 353 - createMutex.lock() 354 - if (session != null && client != null) { 355 - createMutex.unlock() 356 - return Result.success(Unit) 357 - } 358 - 359 - Log.d("Bluesky", "create called without session or client") 360 - val pdsURLFlow: Flow<String> = context.dataStore.data.map { settings -> 361 - settings[PDSHOST] ?: "" 362 - } 363 - val sessionDataStringFlow: Flow<String> = context.dataStore.data.map { settings -> 364 - settings[SESSION] ?: "" 365 - } 366 - 367 - val pdsURL = pdsURLFlow.first() 368 - val sessionDataString = sessionDataStringFlow.first() 369 - 370 - if (pdsURL.isEmpty() || sessionDataString.isEmpty()) { 371 - createMutex.unlock() 372 - return Result.failure(Exception("No session data found")) 373 - } 374 - 375 - val sessionData = SessionData.decodeFromJson(sessionDataString) 376 - 377 - refreshIfNeeded(pdsURL, sessionData).onFailure { 378 - createMutex.unlock() 379 - return Result.failure(it) 380 - } 381 - 382 - createMutex.unlock() 383 - } 384 - } 385 - 386 - suspend fun fetchTimeline( 387 - algorithm: String, 388 - cursor: String? = null 389 - ): Result<GetTimelineResponse> { 390 - return runCatching { 391 - create().onFailure { 392 - return Result.failure(LoginException(it.message)) 393 - } 394 - 395 - val timeline = client!!.getTimeline( 396 - GetTimelineQueryParams( 397 - algorithm = algorithm, 398 - limit = 25, 399 - cursor = cursor 400 - ) 401 - ) 402 - val feed = when (timeline) { 403 - is AtpResponse.Failure<*> -> { 404 - return Result.failure(Exception("Failed to fetch timeline: ${timeline.error}")) 405 - } 406 - 407 - is AtpResponse.Success<GetTimelineResponse> -> timeline.response 408 - } 409 - 410 - return Result.success(feed) 411 - } 412 - } 413 - 414 - suspend fun post( 415 - content: String, 416 - images: List<Uri>? = null, 417 - video: Uri? = null, 418 - replyRef: PostReplyRef? = null 419 - ): Result<Unit> { 420 - // TODO: videos need to be uploaded through a different API. 421 - return runCatching { 422 - create().onFailure { 423 - return Result.failure(LoginException(it.message)) 424 - } 425 - 426 - var postEmbed: PostEmbedUnion? = null 427 - 428 - if (images != null) { 429 - val blobs = uploadImages(images).getOrThrow() 430 - postEmbed = PostEmbedUnion.Images( 431 - value = Images( 432 - images = blobs.map { 433 - ImagesImage( 434 - image = it, 435 - alt = "", 436 - ) 437 - } 438 - ) 439 - ) 440 - } 441 - 442 - if (video != null) { 443 - val blob = uploadVideo(video).getOrThrow() 444 - postEmbed = PostEmbedUnion.Video( 445 - value = Video( 446 - video = blob, 447 - alt = "", 448 - ) 449 - ) 450 - } 451 - 452 - val r = BlueskyJson.encodeAsJsonContent( 453 - Post( 454 - text = content, 455 - createdAt = Clock.System.now(), 456 - embed = postEmbed, 457 - reply = replyRef 458 - ) 459 - ) 460 - 461 - val postRes = client!!.createRecord( 462 - CreateRecordRequest( 463 - repo = session!!.handle, // Use handle from the session 464 - collection = Nsid("app.bsky.feed.post"), 465 - record = r, 466 - ) 467 - ) 468 - return when (postRes) { 469 - is AtpResponse.Failure<*> -> Result.failure(Exception("Could not create post: ${postRes.error?.message}")) 470 - is AtpResponse.Success<*> -> Result.success(Unit) 471 - } 472 - } 473 - } 474 - 475 - suspend fun uploadImages(images: List<Uri>): Result<List<Blob>> { 476 - return runCatching { 477 - create().onFailure { 478 - return Result.failure(LoginException(it.message)) 479 - } 480 - 481 - val uploadedBlobs = mutableListOf<Blob>() 482 - 483 - images.forEach { 484 - context.contentResolver.openInputStream(it)?.use { inputStream -> 485 - val byteArray = inputStream.readBytes() 486 - val blob = client!!.uploadBlob(byteArray) 487 - when (blob) { 488 - is AtpResponse.Failure<*> -> return Result.failure(Exception("Failed uploading image: ${blob.error}")) 489 - is AtpResponse.Success<UploadBlobResponse> -> { 490 - uploadedBlobs.add(blob.response.blob) 491 - } 492 - } 493 - } 494 - } 495 - 496 - return Result.success(uploadedBlobs) 497 - } 498 - } 499 - 500 - suspend fun uploadVideo(video: Uri): Result<Blob> { 501 - return runCatching { 502 - create().onFailure { 503 - return Result.failure(LoginException(it.message)) 504 - } 505 - 506 - val uploadedBlobs = mutableListOf<Blob>() 507 - 508 - context.contentResolver.openInputStream(video)?.use { inputStream -> 509 - val byteArray = inputStream.readBytes() 510 - val blob = client!!.uploadBlob(byteArray) 511 - when (blob) { 512 - is AtpResponse.Failure<*> -> return Result.failure(Exception("Failed uploading video: ${blob.error}")) 513 - is AtpResponse.Success<UploadBlobResponse> -> { 514 - uploadedBlobs.add(blob.response.blob) 515 - } 516 - } 517 - } 518 - 519 - 520 - return Result.success(uploadedBlobs.first()) 521 - } 522 - } 523 - 524 - suspend fun feeds(): Result<List<GeneratorView>> { 525 - return runCatching { 526 - create().onFailure { 527 - return Result.failure(LoginException(it.message)) 528 - } 529 - val prefs = client!!.getPreferences().requireResponse() 530 - val feedUris = (prefs.preferences.first { 531 - when (it) { 532 - is PreferencesUnion.SavedFeedsPrefV2 -> true 533 - else -> false 534 - } 535 - } as PreferencesUnion.SavedFeedsPrefV2).value.items.filter { 536 - it.type.value != "timeline" 537 - }.map { AtUri(it.value) } 538 - 539 - val resp = client!!.getFeedGenerators( 540 - GetFeedGeneratorsQueryParams( 541 - feedUris 542 - ) 543 - ).requireResponse() 544 - 545 - return Result.success(resp.feeds) 546 - } 547 - } 548 - 549 - suspend fun like(uri: AtUri, cid: Cid): Result<RKey> { 550 - return runCatching { 551 - create().onFailure { 552 - return Result.failure(LoginException(it.message)) 553 - } 554 - 555 - val like = BlueskyJson.encodeAsJsonContent( 556 - Like( 557 - subject = StrongRef(uri, cid), 558 - createdAt = Clock.System.now(), 559 - ) 560 - ) 561 - 562 - 563 - val likeRes = client!!.createRecord( 564 - CreateRecordRequest( 565 - repo = session!!.handle, 566 - collection = Nsid("app.bsky.feed.like"), 567 - record = like, 568 - ) 569 - ) 570 - 571 - return when (likeRes) { 572 - is AtpResponse.Failure<*> -> Result.failure(Exception("Could not like post: ${likeRes.error?.message}")) 573 - is AtpResponse.Success<CreateRecordResponse> -> Result.success( 574 - RKey(likeRes.response.uri.atUri.toUri().lastPathSegment!!) 575 - ) 576 - } 577 - } 578 - } 579 - 580 - suspend fun repost(uri: AtUri, cid: Cid): Result<RKey> { 581 - return runCatching { 582 - create().onFailure { 583 - return Result.failure(LoginException(it.message)) 584 - } 585 - 586 - val like = BlueskyJson.encodeAsJsonContent( 587 - Repost( 588 - subject = StrongRef(uri, cid), 589 - createdAt = Clock.System.now(), 590 - ) 591 - ) 592 - 593 - 594 - val likeRes = client!!.createRecord( 595 - CreateRecordRequest( 596 - repo = session!!.handle, 597 - collection = Nsid("app.bsky.feed.repost"), 598 - record = like, 599 - ) 600 - ) 601 - 602 - return when (likeRes) { 603 - is AtpResponse.Failure<*> -> Result.failure(Exception("Could not repost: ${likeRes.error?.message}")) 604 - is AtpResponse.Success<CreateRecordResponse> -> Result.success( 605 - likeRes.response.uri.rkey() 606 - ) 607 - } 608 - } 609 - } 610 - 611 - suspend fun deleteLike(rKey: RKey): Result<Unit> { 612 - return deleteRecord(rKey, "app.bsky.feed.like") 613 - } 614 - 615 - suspend fun deleteRepost(rKey: RKey): Result<Unit> { 616 - return deleteRecord(rKey, "app.bsky.feed.repost") 617 - } 618 - 619 - private suspend fun deleteRecord(rKey: RKey, collection: String): Result<Unit> { 620 - return runCatching { 621 - create().onFailure { 622 - return Result.failure(LoginException(it.message)) 623 - } 624 - 625 - val delRes = client!!.deleteRecord( 626 - DeleteRecordRequest( 627 - repo = session!!.handle, 628 - collection = Nsid(collection), 629 - rkey = rKey, 630 - ) 631 - ) 632 - 633 - return when (delRes) { 634 - is AtpResponse.Failure<*> -> Result.failure(Exception("Could not delete record: ${delRes.error?.message}")) 635 - is AtpResponse.Success<*> -> Result.success(Unit) 636 - } 637 - 638 - } 639 - } 1 + package industries.geesawra.monarch.datalayer 2 + 3 + import android.content.Context 4 + import android.net.Uri 5 + import android.util.Log 6 + import androidx.compose.ui.text.intl.Locale 7 + import androidx.compose.ui.text.toLowerCase 8 + import androidx.core.net.toUri 9 + import androidx.datastore.preferences.core.edit 10 + import androidx.datastore.preferences.core.stringPreferencesKey 11 + import androidx.datastore.preferences.preferencesDataStore 12 + import app.bsky.actor.PreferencesUnion 13 + import app.bsky.embed.Images 14 + import app.bsky.embed.ImagesImage 15 + import app.bsky.embed.Video 16 + import app.bsky.feed.GeneratorView 17 + import app.bsky.feed.GetFeedGeneratorsQueryParams 18 + import app.bsky.feed.GetTimelineQueryParams 19 + import app.bsky.feed.GetTimelineResponse 20 + import app.bsky.feed.Like 21 + import app.bsky.feed.Post 22 + import app.bsky.feed.PostEmbedUnion 23 + import app.bsky.feed.PostReplyRef 24 + import app.bsky.feed.Repost 25 + import com.atproto.identity.ResolveHandleQueryParams 26 + import com.atproto.identity.ResolveHandleResponse 27 + import com.atproto.repo.CreateRecordRequest 28 + import com.atproto.repo.CreateRecordResponse 29 + import com.atproto.repo.DeleteRecordRequest 30 + import com.atproto.repo.StrongRef 31 + import com.atproto.repo.UploadBlobResponse 32 + import com.atproto.server.CreateSessionRequest 33 + import com.atproto.server.CreateSessionResponse 34 + import com.atproto.server.RefreshSessionResponse 35 + import industries.geesawra.monarch.rkey 36 + import io.ktor.client.HttpClient 37 + import io.ktor.client.call.body 38 + import io.ktor.client.engine.okhttp.OkHttp 39 + import io.ktor.client.plugins.HttpTimeout 40 + import io.ktor.client.plugins.defaultRequest 41 + import io.ktor.client.request.get 42 + import io.ktor.client.request.post 43 + import io.ktor.http.HttpStatusCode 44 + import io.ktor.http.URLProtocol 45 + import io.ktor.http.path 46 + import kotlinx.coroutines.flow.Flow 47 + import kotlinx.coroutines.flow.first 48 + import kotlinx.coroutines.flow.map 49 + import kotlinx.coroutines.sync.Mutex 50 + import kotlinx.datetime.Clock 51 + import kotlinx.serialization.Serializable 52 + import sh.christian.ozone.BlueskyJson 53 + import sh.christian.ozone.XrpcBlueskyApi 54 + import sh.christian.ozone.api.AtUri 55 + import sh.christian.ozone.api.AuthenticatedXrpcBlueskyApi 56 + import sh.christian.ozone.api.BlueskyAuthPlugin 57 + import sh.christian.ozone.api.Cid 58 + import sh.christian.ozone.api.Did 59 + import sh.christian.ozone.api.Handle 60 + import sh.christian.ozone.api.Nsid 61 + import sh.christian.ozone.api.RKey 62 + import sh.christian.ozone.api.model.Blob 63 + import sh.christian.ozone.api.model.JsonContent.Companion.encodeAsJsonContent 64 + import sh.christian.ozone.api.response.AtpResponse 65 + 66 + enum class AuthData { 67 + PDSHost, 68 + SessionData, 69 + } 70 + 71 + class LoginException(message: String?) : Exception(message) 72 + 73 + @Serializable // Added annotation 74 + data class SessionData( 75 + val accessJwt: String, 76 + val refreshJwt: String, 77 + val handle: Handle, 78 + val did: Did, 79 + val active: Boolean? = null, 80 + ) { 81 + fun encodeToJson(): String { 82 + return BlueskyJson.encodeToString(serializer(), this) 83 + } 84 + 85 + companion object { 86 + fun decodeFromJson(jsonString: String): SessionData { 87 + return BlueskyJson.decodeFromString(serializer(), jsonString) 88 + } 89 + 90 + fun fromCreateSessionResponse(session: CreateSessionResponse): SessionData { 91 + return SessionData( 92 + accessJwt = session.accessJwt, 93 + refreshJwt = session.refreshJwt, 94 + handle = session.handle, 95 + did = session.did, 96 + active = session.active, 97 + ) 98 + } 99 + 100 + fun fromRefreshSessionResponse(session: RefreshSessionResponse): SessionData { 101 + return SessionData( 102 + accessJwt = session.accessJwt, 103 + refreshJwt = session.refreshJwt, 104 + handle = session.handle, 105 + did = session.did, 106 + active = session.active, 107 + ) 108 + } 109 + } 110 + } 111 + 112 + 113 + class BlueskyConn(val context: Context) { 114 + companion object { 115 + private val Context.dataStore by preferencesDataStore("bluesky") 116 + private val SESSION = stringPreferencesKey(AuthData.SessionData.name) 117 + private val PDSHOST = stringPreferencesKey(AuthData.PDSHost.name) 118 + 119 + suspend fun pdsForHandle(handle: String): Result<String> { 120 + return runCatching { 121 + val api = XrpcBlueskyApi() 122 + 123 + val rawId = api.resolveHandle( 124 + ResolveHandleQueryParams( 125 + handle = Handle(handle) 126 + ) 127 + ) 128 + 129 + val did = when (rawId) { 130 + is AtpResponse.Failure<*> -> { 131 + return Result.failure(Exception("Failed to resolve handle: ${rawId.error?.message}")) 132 + } 133 + 134 + is AtpResponse.Success<ResolveHandleResponse> -> { 135 + rawId.response.did.did 136 + } 137 + } 138 + 139 + val httpClient = HttpClient(OkHttp) { 140 + install(HttpTimeout) { 141 + requestTimeoutMillis = 15000 142 + connectTimeoutMillis = 15000 143 + socketTimeoutMillis = 15000 144 + } 145 + } 146 + 147 + val rawDoc = httpClient.get { 148 + url { 149 + protocol = URLProtocol.HTTPS 150 + host = "plc.directory" 151 + path(did) 152 + } 153 + } 154 + 155 + if (rawDoc.status != HttpStatusCode.OK) { 156 + return Result.failure(Exception("PLC lookup HTTP status code ${rawDoc.status}")) 157 + } 158 + 159 + val body: String = rawDoc.body() 160 + 161 + val solvedDoc: didDoc = BlueskyJson.decodeFromString(didDoc.serializer(), body) 162 + 163 + for (ps in solvedDoc.service) { 164 + if (ps.id == "#atproto_pds" && ps.type == "AtprotoPersonalDataServer") { 165 + return Result.success(ps.serviceEndpoint) 166 + } 167 + } 168 + 169 + return Result.failure(Exception("No PDS service defined in the DID document associated with ${handle}")) 170 + } 171 + } 172 + } 173 + 174 + @Serializable 175 + private data class service( 176 + val id: String, 177 + val type: String, 178 + val serviceEndpoint: String 179 + ) 180 + 181 + @Serializable 182 + private data class didDoc( 183 + val service: List<service> 184 + ) 185 + 186 + var client: AuthenticatedXrpcBlueskyApi? = null 187 + var session: SessionData? = null 188 + var createMutex: Mutex = Mutex() 189 + 190 + suspend fun storeSessionData(pdsURL: String, session: SessionData) { 191 + context.dataStore.edit { settings -> 192 + settings[SESSION] = session.encodeToJson() 193 + settings[PDSHOST] = pdsURL 194 + } 195 + } 196 + 197 + suspend fun cleanSessionData() { 198 + context.dataStore.edit { settings -> 199 + settings.remove(SESSION) 200 + settings.remove(PDSHOST) 201 + } 202 + } 203 + 204 + suspend fun hasSession(): Boolean { 205 + val pdsURLFlow: Flow<String> = context.dataStore.data.map { settings -> 206 + settings[PDSHOST] ?: "" 207 + } 208 + val sessionDataStringFlow: Flow<String> = context.dataStore.data.map { settings -> 209 + settings[SESSION] ?: "" 210 + } 211 + 212 + val pdsURL = pdsURLFlow.first() 213 + val sessionDataString = sessionDataStringFlow.first() 214 + 215 + return !(pdsURL.isEmpty() || sessionDataString.isEmpty()) 216 + } 217 + 218 + suspend fun login(pdsURL: String, handle: String, password: String): Result<Unit> { 219 + createMutex.lock() 220 + val httpClient = HttpClient(OkHttp) { 221 + defaultRequest { 222 + url(pdsURL) 223 + } 224 + install(HttpTimeout) { 225 + requestTimeoutMillis = 15000 226 + connectTimeoutMillis = 15000 227 + socketTimeoutMillis = 15000 228 + } 229 + } 230 + 231 + val client = XrpcBlueskyApi(httpClient) 232 + 233 + val s = client.createSession(CreateSessionRequest(handle, password)) 234 + val sessionResponse: CreateSessionResponse = when (s) { 235 + is AtpResponse.Failure<*> -> { 236 + createMutex.unlock() 237 + return Result.failure( 238 + Exception( 239 + "Failed to create session: ${ 240 + s.error?.message?.toLowerCase( 241 + Locale.current 242 + ) 243 + }" 244 + ) 245 + ) 246 + } 247 + 248 + is AtpResponse.Success<CreateSessionResponse> -> s.response 249 + } 250 + 251 + storeSessionData(pdsURL, SessionData.fromCreateSessionResponse(sessionResponse)) 252 + session = null 253 + this.client = null 254 + 255 + createMutex.unlock() 256 + return Result.success(Unit) 257 + } 258 + 259 + @Serializable 260 + private data class atpError( 261 + val error: String?, 262 + val message: String?, 263 + ) 264 + 265 + private suspend fun refreshIfNeeded(pdsURL: String, token: SessionData): Result<Unit> { 266 + return runCatching { 267 + val httpClient = HttpClient(OkHttp) { 268 + defaultRequest { 269 + url(pdsURL) 270 + } 271 + install(HttpTimeout) { 272 + requestTimeoutMillis = 15000 273 + connectTimeoutMillis = 15000 274 + socketTimeoutMillis = 15000 275 + } 276 + } 277 + 278 + val gs = httpClient.get { 279 + headers["Authorization"] = "Bearer " + token.accessJwt 280 + url { 281 + protocol = URLProtocol.HTTPS 282 + path("/xrpc/com.atproto.server.getSession") 283 + } 284 + } 285 + 286 + when (gs.status) { 287 + 288 + HttpStatusCode.OK -> run { 289 + this.session = token 290 + val tokens = 291 + BlueskyAuthPlugin.Tokens(token.accessJwt, token.refreshJwt) 292 + this.client = AuthenticatedXrpcBlueskyApi(httpClient, tokens) 293 + return Result.success(Unit) 294 + } 295 + 296 + else -> run { 297 + val body: String = gs.body() 298 + 299 + val error: atpError = 300 + BlueskyJson.decodeFromString( 301 + atpError.serializer(), 302 + body 303 + ) 304 + if (error.error == "ExpiredToken") { 305 + return@run 306 + } 307 + cleanSessionData() 308 + return Result.failure(Exception("Session checking failed, status code ${gs.status}: ${error.message}")) 309 + } 310 + } 311 + 312 + val rs = httpClient.post { 313 + headers["Authorization"] = "Bearer " + token.refreshJwt 314 + url { 315 + protocol = URLProtocol.HTTPS 316 + path("/xrpc/com.atproto.server.refreshSession") 317 + } 318 + } 319 + 320 + when (rs.status) { 321 + 322 + HttpStatusCode.OK -> run { 323 + val body: String = rs.body() 324 + val rs: RefreshSessionResponse = 325 + BlueskyJson.decodeFromString( 326 + RefreshSessionResponse.serializer(), 327 + body 328 + ) 329 + 330 + this.session = SessionData.fromRefreshSessionResponse(rs) 331 + storeSessionData(pdsURL, this.session!!) 332 + return Result.success(Unit) 333 + } 334 + 335 + else -> run { 336 + val body: String = rs.body() 337 + 338 + val error: atpError = 339 + BlueskyJson.decodeFromString( 340 + atpError.serializer(), 341 + body 342 + ) 343 + cleanSessionData() 344 + return Result.failure(Exception("Login refresh failed, status code ${rs.status}: ${error.message}")) 345 + } 346 + } 347 + 348 + } 349 + } 350 + 351 + suspend fun create(): Result<Unit> { 352 + return runCatching { 353 + createMutex.lock() 354 + if (session != null && client != null) { 355 + createMutex.unlock() 356 + return Result.success(Unit) 357 + } 358 + 359 + Log.d("Bluesky", "create called without session or client") 360 + val pdsURLFlow: Flow<String> = context.dataStore.data.map { settings -> 361 + settings[PDSHOST] ?: "" 362 + } 363 + val sessionDataStringFlow: Flow<String> = context.dataStore.data.map { settings -> 364 + settings[SESSION] ?: "" 365 + } 366 + 367 + val pdsURL = pdsURLFlow.first() 368 + val sessionDataString = sessionDataStringFlow.first() 369 + 370 + if (pdsURL.isEmpty() || sessionDataString.isEmpty()) { 371 + createMutex.unlock() 372 + return Result.failure(Exception("No session data found")) 373 + } 374 + 375 + val sessionData = SessionData.decodeFromJson(sessionDataString) 376 + 377 + refreshIfNeeded(pdsURL, sessionData).onFailure { 378 + createMutex.unlock() 379 + return Result.failure(it) 380 + } 381 + 382 + createMutex.unlock() 383 + } 384 + } 385 + 386 + suspend fun fetchTimeline( 387 + algorithm: String, 388 + cursor: String? = null 389 + ): Result<GetTimelineResponse> { 390 + return runCatching { 391 + create().onFailure { 392 + return Result.failure(LoginException(it.message)) 393 + } 394 + 395 + val timeline = client!!.getTimeline( 396 + GetTimelineQueryParams( 397 + algorithm = algorithm, 398 + limit = 25, 399 + cursor = cursor 400 + ) 401 + ) 402 + val feed = when (timeline) { 403 + is AtpResponse.Failure<*> -> { 404 + return Result.failure(Exception("Failed to fetch timeline: ${timeline.error}")) 405 + } 406 + 407 + is AtpResponse.Success<GetTimelineResponse> -> timeline.response 408 + } 409 + 410 + return Result.success(feed) 411 + } 412 + } 413 + 414 + suspend fun post( 415 + content: String, 416 + images: List<Uri>? = null, 417 + video: Uri? = null, 418 + replyRef: PostReplyRef? = null 419 + ): Result<Unit> { 420 + // TODO: videos need to be uploaded through a different API. 421 + return runCatching { 422 + create().onFailure { 423 + return Result.failure(LoginException(it.message)) 424 + } 425 + 426 + var postEmbed: PostEmbedUnion? = null 427 + 428 + if (images != null) { 429 + val blobs = uploadImages(images).getOrThrow() 430 + postEmbed = PostEmbedUnion.Images( 431 + value = Images( 432 + images = blobs.map { 433 + ImagesImage( 434 + image = it, 435 + alt = "", 436 + ) 437 + } 438 + ) 439 + ) 440 + } 441 + 442 + if (video != null) { 443 + val blob = uploadVideo(video).getOrThrow() 444 + postEmbed = PostEmbedUnion.Video( 445 + value = Video( 446 + video = blob, 447 + alt = "", 448 + ) 449 + ) 450 + } 451 + 452 + val r = BlueskyJson.encodeAsJsonContent( 453 + Post( 454 + text = content, 455 + createdAt = Clock.System.now(), 456 + embed = postEmbed, 457 + reply = replyRef 458 + ) 459 + ) 460 + 461 + val postRes = client!!.createRecord( 462 + CreateRecordRequest( 463 + repo = session!!.handle, // Use handle from the session 464 + collection = Nsid("app.bsky.feed.post"), 465 + record = r, 466 + ) 467 + ) 468 + return when (postRes) { 469 + is AtpResponse.Failure<*> -> Result.failure(Exception("Could not create post: ${postRes.error?.message}")) 470 + is AtpResponse.Success<*> -> Result.success(Unit) 471 + } 472 + } 473 + } 474 + 475 + suspend fun uploadImages(images: List<Uri>): Result<List<Blob>> { 476 + return runCatching { 477 + create().onFailure { 478 + return Result.failure(LoginException(it.message)) 479 + } 480 + 481 + val uploadedBlobs = mutableListOf<Blob>() 482 + 483 + images.forEach { 484 + context.contentResolver.openInputStream(it)?.use { inputStream -> 485 + val byteArray = inputStream.readBytes() 486 + val blob = client!!.uploadBlob(byteArray) 487 + when (blob) { 488 + is AtpResponse.Failure<*> -> return Result.failure(Exception("Failed uploading image: ${blob.error}")) 489 + is AtpResponse.Success<UploadBlobResponse> -> { 490 + uploadedBlobs.add(blob.response.blob) 491 + } 492 + } 493 + } 494 + } 495 + 496 + return Result.success(uploadedBlobs) 497 + } 498 + } 499 + 500 + suspend fun uploadVideo(video: Uri): Result<Blob> { 501 + return runCatching { 502 + create().onFailure { 503 + return Result.failure(LoginException(it.message)) 504 + } 505 + 506 + val uploadedBlobs = mutableListOf<Blob>() 507 + 508 + context.contentResolver.openInputStream(video)?.use { inputStream -> 509 + val byteArray = inputStream.readBytes() 510 + val blob = client!!.uploadBlob(byteArray) 511 + when (blob) { 512 + is AtpResponse.Failure<*> -> return Result.failure(Exception("Failed uploading video: ${blob.error}")) 513 + is AtpResponse.Success<UploadBlobResponse> -> { 514 + uploadedBlobs.add(blob.response.blob) 515 + } 516 + } 517 + } 518 + 519 + 520 + return Result.success(uploadedBlobs.first()) 521 + } 522 + } 523 + 524 + suspend fun feeds(): Result<List<GeneratorView>> { 525 + return runCatching { 526 + create().onFailure { 527 + return Result.failure(LoginException(it.message)) 528 + } 529 + val prefs = client!!.getPreferences().requireResponse() 530 + val feedUris = (prefs.preferences.first { 531 + when (it) { 532 + is PreferencesUnion.SavedFeedsPrefV2 -> true 533 + else -> false 534 + } 535 + } as PreferencesUnion.SavedFeedsPrefV2).value.items.filter { 536 + it.type.value != "timeline" 537 + }.map { AtUri(it.value) } 538 + 539 + val resp = client!!.getFeedGenerators( 540 + GetFeedGeneratorsQueryParams( 541 + feedUris 542 + ) 543 + ).requireResponse() 544 + 545 + return Result.success(resp.feeds) 546 + } 547 + } 548 + 549 + suspend fun like(uri: AtUri, cid: Cid): Result<RKey> { 550 + return runCatching { 551 + create().onFailure { 552 + return Result.failure(LoginException(it.message)) 553 + } 554 + 555 + val like = BlueskyJson.encodeAsJsonContent( 556 + Like( 557 + subject = StrongRef(uri, cid), 558 + createdAt = Clock.System.now(), 559 + ) 560 + ) 561 + 562 + 563 + val likeRes = client!!.createRecord( 564 + CreateRecordRequest( 565 + repo = session!!.handle, 566 + collection = Nsid("app.bsky.feed.like"), 567 + record = like, 568 + ) 569 + ) 570 + 571 + return when (likeRes) { 572 + is AtpResponse.Failure<*> -> Result.failure(Exception("Could not like post: ${likeRes.error?.message}")) 573 + is AtpResponse.Success<CreateRecordResponse> -> Result.success( 574 + RKey(likeRes.response.uri.atUri.toUri().lastPathSegment!!) 575 + ) 576 + } 577 + } 578 + } 579 + 580 + suspend fun repost(uri: AtUri, cid: Cid): Result<RKey> { 581 + return runCatching { 582 + create().onFailure { 583 + return Result.failure(LoginException(it.message)) 584 + } 585 + 586 + val like = BlueskyJson.encodeAsJsonContent( 587 + Repost( 588 + subject = StrongRef(uri, cid), 589 + createdAt = Clock.System.now(), 590 + ) 591 + ) 592 + 593 + 594 + val likeRes = client!!.createRecord( 595 + CreateRecordRequest( 596 + repo = session!!.handle, 597 + collection = Nsid("app.bsky.feed.repost"), 598 + record = like, 599 + ) 600 + ) 601 + 602 + return when (likeRes) { 603 + is AtpResponse.Failure<*> -> Result.failure(Exception("Could not repost: ${likeRes.error?.message}")) 604 + is AtpResponse.Success<CreateRecordResponse> -> Result.success( 605 + likeRes.response.uri.rkey() 606 + ) 607 + } 608 + } 609 + } 610 + 611 + suspend fun deleteLike(rKey: RKey): Result<Unit> { 612 + return deleteRecord(rKey, "app.bsky.feed.like") 613 + } 614 + 615 + suspend fun deleteRepost(rKey: RKey): Result<Unit> { 616 + return deleteRecord(rKey, "app.bsky.feed.repost") 617 + } 618 + 619 + private suspend fun deleteRecord(rKey: RKey, collection: String): Result<Unit> { 620 + return runCatching { 621 + create().onFailure { 622 + return Result.failure(LoginException(it.message)) 623 + } 624 + 625 + val delRes = client!!.deleteRecord( 626 + DeleteRecordRequest( 627 + repo = session!!.handle, 628 + collection = Nsid(collection), 629 + rkey = rKey, 630 + ) 631 + ) 632 + 633 + return when (delRes) { 634 + is AtpResponse.Failure<*> -> Result.failure(Exception("Could not delete record: ${delRes.error?.message}")) 635 + is AtpResponse.Success<*> -> Result.success(Unit) 636 + } 637 + 638 + } 639 + } 640 640 }
+1 -1
app/src/main/java/industries/geesawra/jerryno/datalayer/Models.kt app/src/main/java/industries/geesawra/monarch/datalayer/Models.kt
··· 1 - package industries.geesawra.jerryno.datalayer 1 + package industries.geesawra.monarch.datalayer 2 2 3 3 import app.bsky.embed.RecordViewRecord 4 4 import app.bsky.embed.RecordViewRecordEmbedUnion
+1 -1
app/src/main/java/industries/geesawra/jerryno/datalayer/TimelineViewModel.kt app/src/main/java/industries/geesawra/monarch/datalayer/TimelineViewModel.kt
··· 1 - package industries.geesawra.jerryno.datalayer 1 + package industries.geesawra.monarch.datalayer 2 2 3 3 import android.net.Uri 4 4 import androidx.compose.runtime.getValue
app/src/main/java/industries/geesawra/jerryno/thirdpartyforks/compose-video/RepeatMode.kt app/src/main/java/industries/geesawra/monarch/thirdpartyforks/compose-video/RepeatMode.kt
app/src/main/java/industries/geesawra/jerryno/thirdpartyforks/compose-video/ResizeMode.kt app/src/main/java/industries/geesawra/monarch/thirdpartyforks/compose-video/ResizeMode.kt
app/src/main/java/industries/geesawra/jerryno/thirdpartyforks/compose-video/VideoPlayer.kt app/src/main/java/industries/geesawra/monarch/thirdpartyforks/compose-video/VideoPlayer.kt
app/src/main/java/industries/geesawra/jerryno/thirdpartyforks/compose-video/VideoPlayerFullScreenDialog.kt app/src/main/java/industries/geesawra/monarch/thirdpartyforks/compose-video/VideoPlayerFullScreenDialog.kt
app/src/main/java/industries/geesawra/jerryno/thirdpartyforks/compose-video/cache/VideoPlayerCacheManager.kt app/src/main/java/industries/geesawra/monarch/thirdpartyforks/compose-video/cache/VideoPlayerCacheManager.kt
app/src/main/java/industries/geesawra/jerryno/thirdpartyforks/compose-video/controller/VideoPlayerControllerConfig.kt app/src/main/java/industries/geesawra/monarch/thirdpartyforks/compose-video/controller/VideoPlayerControllerConfig.kt
app/src/main/java/industries/geesawra/jerryno/thirdpartyforks/compose-video/pip/PictureInPicture.kt app/src/main/java/industries/geesawra/monarch/thirdpartyforks/compose-video/pip/PictureInPicture.kt
app/src/main/java/industries/geesawra/jerryno/thirdpartyforks/compose-video/uri/VideoPlayerMediaItem.kt app/src/main/java/industries/geesawra/monarch/thirdpartyforks/compose-video/uri/VideoPlayerMediaItem.kt
app/src/main/java/industries/geesawra/jerryno/thirdpartyforks/compose-video/uri/VideoPlayerMediaItemConverter.kt app/src/main/java/industries/geesawra/monarch/thirdpartyforks/compose-video/uri/VideoPlayerMediaItemConverter.kt
app/src/main/java/industries/geesawra/jerryno/thirdpartyforks/compose-video/util/ContextUtil.kt app/src/main/java/industries/geesawra/monarch/thirdpartyforks/compose-video/util/ContextUtil.kt
app/src/main/java/industries/geesawra/jerryno/thirdpartyforks/compose-video/util/WindowUtil.kt app/src/main/java/industries/geesawra/monarch/thirdpartyforks/compose-video/util/WindowUtil.kt
+1 -1
app/src/main/java/industries/geesawra/jerryno/ui/theme/Color.kt app/src/main/java/industries/geesawra/monarch/ui/theme/Color.kt
··· 1 - package industries.geesawra.jerryno.ui.theme 1 + package industries.geesawra.monarch.ui.theme 2 2 3 3 import androidx.compose.ui.graphics.Color 4 4
+2 -3
app/src/main/java/industries/geesawra/jerryno/ui/theme/Theme.kt app/src/main/java/industries/geesawra/monarch/ui/theme/Theme.kt
··· 1 - package industries.geesawra.jerryno.ui.theme 1 + package industries.geesawra.monarch.ui.theme 2 2 3 - import android.app.Activity 4 3 import android.os.Build 5 4 import androidx.compose.foundation.isSystemInDarkTheme 6 5 import androidx.compose.material3.MaterialTheme ··· 34 33 ) 35 34 36 35 @Composable 37 - fun JerryNoTheme( 36 + fun MonarchTheme( 38 37 darkTheme: Boolean = isSystemInDarkTheme(), 39 38 // Dynamic color is available on Android 12+ 40 39 dynamicColor: Boolean = true,
+1 -1
app/src/main/java/industries/geesawra/jerryno/ui/theme/Type.kt app/src/main/java/industries/geesawra/monarch/ui/theme/Type.kt
··· 1 - package industries.geesawra.jerryno.ui.theme 1 + package industries.geesawra.monarch.ui.theme 2 2 3 3 import androidx.compose.material3.Typography 4 4 import androidx.compose.ui.text.TextStyle
+1 -1
app/src/main/res/values/strings.xml
··· 1 1 <resources> 2 - <string name="app_name">Jerry No</string> 2 + <string name="app_name">Monarch</string> 3 3 <string name="timeline">Timeline</string> 4 4 <string name="notifications">Notifications</string> 5 5 </resources>
+1 -1
app/src/main/res/values/themes.xml
··· 1 1 <?xml version="1.0" encoding="utf-8"?> 2 2 <resources> 3 3 4 - <style name="Theme.JerryNo" parent="android:Theme.Material.Light.NoActionBar" /> 4 + <style name="Theme.Monarch" parent="android:Theme.Material.Light.NoActionBar" /> 5 5 </resources>
+2 -3
app/src/test/java/industries/geesawra/jerryno/ExampleUnitTest.kt app/src/test/java/industries/geesawra/monarch/ExampleUnitTest.kt
··· 1 - package industries.geesawra.jerryno 1 + package industries.geesawra.monarch 2 2 3 + import org.junit.Assert.assertEquals 3 4 import org.junit.Test 4 - 5 - import org.junit.Assert.* 6 5 7 6 /** 8 7 * Example local unit test, which will execute on the development machine (host).
+1 -1
settings.gradle.kts
··· 27 27 maven { url = uri("https://www.jitpack.io") } 28 28 } 29 29 } 30 - rootProject.name = "Jerry No" 30 + rootProject.name = "Monarch" 31 31 include(":app")