+21
.idea/appInsightsSettings.xml
+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
+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
+8
.idea/dictionaries/project.xml
+6
.idea/studiobot.xml
+6
.idea/studiobot.xml
+6
.idea/vcs.xml
+6
.idea/vcs.xml
+9
-1
app/build.gradle.kts
+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
+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
+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
+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
+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
+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
+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
-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
+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" }