initial commit

Signed-off-by: ale <ale@manalejandro.com>
Este commit está contenido en:
ale
2026-01-24 17:45:29 +01:00
commit 8c7417f913
Se han modificado 73 ficheros con 6362 adiciones y 0 borrados

Ver fichero

@@ -0,0 +1,24 @@
package com.manalejandro.myactivitypub
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Test
import org.junit.runner.RunWith
import org.junit.Assert.*
/**
* Instrumented test, which will execute on an Android device.
*
* See [testing documentation](http://d.android.com/tools/testing).
*/
@RunWith(AndroidJUnit4::class)
class ExampleInstrumentedTest {
@Test
fun useAppContext() {
// Context of the app under test.
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
assertEquals("com.manalejandro.myactivitypub", appContext.packageName)
}
}

Ver fichero

@@ -0,0 +1,41 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<application
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.MyActivityPub"
android:usesCleartextTraffic="false">
<activity
android:name=".MainActivity"
android:exported="true"
android:label="@string/app_name"
android:theme="@style/Theme.MyActivityPub"
android:launchMode="singleTask">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<!-- OAuth callback -->
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data
android:scheme="myactivitypub"
android:host="oauth" />
</intent-filter>
</activity>
</application>
</manifest>

Archivo binario no mostrado.

Después

Anchura:  |  Altura:  |  Tamaño: 25 KiB

Ver fichero

@@ -0,0 +1,605 @@
package com.manalejandro.myactivitypub
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.BackHandler
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.browser.customtabs.CustomTabsIntent
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.ExitToApp
import androidx.compose.material.icons.automirrored.filled.Login
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.manalejandro.myactivitypub.data.api.MastodonApiService
import com.manalejandro.myactivitypub.data.repository.AuthRepository
import com.manalejandro.myactivitypub.data.repository.MastodonRepository
import com.manalejandro.myactivitypub.ui.components.StatusCard
import com.manalejandro.myactivitypub.ui.screens.LoginScreen
import com.manalejandro.myactivitypub.ui.theme.MyActivityPubTheme
import com.manalejandro.myactivitypub.ui.viewmodel.AuthState
import com.manalejandro.myactivitypub.ui.viewmodel.AuthViewModel
import com.manalejandro.myactivitypub.ui.viewmodel.InteractionState
import com.manalejandro.myactivitypub.ui.viewmodel.TimelineType
import com.manalejandro.myactivitypub.ui.viewmodel.TimelineUiState
import com.manalejandro.myactivitypub.ui.viewmodel.TimelineViewModel
import okhttp3.Interceptor
import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
import java.util.concurrent.TimeUnit
/**
* Main Activity for My ActivityPub app
* Supports OAuth login and displays public/home timelines
*/
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
MyActivityPubTheme {
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background
) {
MyActivityPubApp()
}
}
}
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
setIntent(intent)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MyActivityPubApp() {
val context = LocalContext.current
val activity = context as? ComponentActivity
// Extract OAuth code from intent
var authCode by remember { mutableStateOf<String?>(null) }
// Check for OAuth code in intent
LaunchedEffect(activity?.intent) {
activity?.intent?.data?.let { uri ->
println("MainActivity: Received intent with URI: $uri")
if (uri.scheme == "myactivitypub" && uri.host == "oauth") {
val code = uri.getQueryParameter("code")
println("MainActivity: Extracted OAuth code: ${code?.take(10)}...")
authCode = code
}
}
}
// Track if we've processed the auth code
var hasProcessedAuthCode by remember { mutableStateOf(false) }
// Setup API service creator
val createApiService: (String, String?) -> MastodonApiService = remember {
{ baseUrl, token ->
val loggingInterceptor = HttpLoggingInterceptor().apply {
level = HttpLoggingInterceptor.Level.BASIC
}
val authInterceptor = Interceptor { chain ->
val request = if (token != null) {
chain.request().newBuilder()
.addHeader("Authorization", "Bearer $token")
.build()
} else {
chain.request()
}
chain.proceed(request)
}
val client = OkHttpClient.Builder()
.addInterceptor(loggingInterceptor)
.addInterceptor(authInterceptor)
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.build()
Retrofit.Builder()
.baseUrl("https://$baseUrl/")
.client(client)
.addConverterFactory(GsonConverterFactory.create())
.build()
.create(MastodonApiService::class.java)
}
}
// Setup repositories - Use remember with key to create singleton
val authRepository = remember(context) {
AuthRepository(context) { instance ->
createApiService(instance, null)
}
}
val authViewModel: AuthViewModel = viewModel {
AuthViewModel(authRepository)
}
val userSession by authViewModel.userSession.collectAsState()
val authState by authViewModel.authState.collectAsState()
// Log state changes
LaunchedEffect(userSession) {
println("MainActivity: userSession updated - instance: ${userSession?.instance}, hasToken: ${userSession?.accessToken != null}")
}
LaunchedEffect(authState) {
println("MainActivity: authState = ${authState::class.simpleName}")
}
// Handle OAuth code - only process once
LaunchedEffect(authCode, hasProcessedAuthCode) {
val code = authCode // Create local copy for smart cast
if (code != null && !hasProcessedAuthCode) {
println("MainActivity: Processing OAuth code")
hasProcessedAuthCode = true
authViewModel.completeLogin(code)
} else if (code != null) {
println("MainActivity: OAuth code already processed")
}
}
// Handle authorization redirect
LaunchedEffect(authState) {
if (authState is AuthState.NeedsAuthorization) {
val authUrl = (authState as AuthState.NeedsAuthorization).authUrl
val intent = CustomTabsIntent.Builder().build()
intent.launchUrl(context, Uri.parse(authUrl))
}
}
// Determine which screen to show
var showLoginScreen by remember { mutableStateOf(false) }
// Reset login screen when successfully authenticated
LaunchedEffect(userSession) {
if (userSession != null) {
showLoginScreen = false
}
}
when {
showLoginScreen && userSession == null -> {
LoginScreen(
onLoginClick = { instance ->
authViewModel.startLogin(instance)
},
onContinueAsGuest = {
showLoginScreen = false
},
isLoading = authState is AuthState.LoggingIn,
errorMessage = if (authState is AuthState.Error) (authState as AuthState.Error).message else null
)
}
else -> {
val instance = userSession?.instance ?: "mastodon.social"
val token = userSession?.accessToken
val apiService = remember(instance, token) {
createApiService(instance, token)
}
val repository = remember(apiService) {
MastodonRepository(apiService)
}
val timelineViewModel: TimelineViewModel = viewModel(key = "$instance-$token") {
TimelineViewModel(repository)
}
TimelineScreen(
viewModel = timelineViewModel,
userSession = userSession,
createApiService = createApiService,
onLoginClick = { showLoginScreen = true },
onLogoutClick = { authViewModel.logout() }
)
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun TimelineScreen(
viewModel: TimelineViewModel,
userSession: com.manalejandro.myactivitypub.data.models.UserSession?,
createApiService: (String, String?) -> MastodonApiService,
onLoginClick: () -> Unit,
onLogoutClick: () -> Unit
) {
val uiState by viewModel.uiState.collectAsState()
val timelineType by viewModel.timelineType.collectAsState()
val interactionState by viewModel.interactionState.collectAsState()
val isLoadingMore by viewModel.isLoadingMore.collectAsState()
var showMenu by remember { mutableStateOf(false) }
var showNotifications by remember { mutableStateOf(false) }
var replyingToStatus by remember { mutableStateOf<com.manalejandro.myactivitypub.data.models.Status?>(null) }
var selectedStatus by remember { mutableStateOf<com.manalejandro.myactivitypub.data.models.Status?>(null) }
// Remember scroll state to preserve position when navigating
val timelineScrollState = rememberLazyListState()
// Handle back button for different states
BackHandler(enabled = selectedStatus != null) {
selectedStatus = null
}
BackHandler(enabled = showNotifications) {
showNotifications = false
}
// Handle interaction state
LaunchedEffect(interactionState) {
when (interactionState) {
is InteractionState.ReplyPosted -> {
replyingToStatus = null
viewModel.resetInteractionState()
}
is InteractionState.Success -> {
// Auto-reset after short delay
kotlinx.coroutines.delay(500)
viewModel.resetInteractionState()
}
else -> {}
}
}
// Reply dialog
replyingToStatus?.let { status ->
com.manalejandro.myactivitypub.ui.components.ReplyDialog(
status = status,
onDismiss = { replyingToStatus = null },
onReply = { content ->
viewModel.postReply(content, status.id)
},
isPosting = interactionState is InteractionState.Processing
)
}
Scaffold(
topBar = {
CenterAlignedTopAppBar(
title = {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(
if (showNotifications) "Notifications" else "My ActivityPub",
style = MaterialTheme.typography.headlineSmall
)
if (userSession != null && !showNotifications) {
Text(
"@${userSession.account?.username ?: "user"}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
},
navigationIcon = {
if (showNotifications) {
IconButton(onClick = { showNotifications = false }) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
}
}
},
actions = {
if (!showNotifications) {
IconButton(onClick = { viewModel.loadTimeline() }) {
Icon(Icons.Default.Refresh, contentDescription = "Refresh")
}
if (userSession != null) {
IconButton(onClick = { showNotifications = true }) {
Icon(Icons.Default.Notifications, contentDescription = "Notifications")
}
}
}
Box {
IconButton(onClick = { showMenu = true }) {
Icon(Icons.Default.MoreVert, contentDescription = "More options")
}
DropdownMenu(
expanded = showMenu,
onDismissRequest = { showMenu = false }
) {
if (userSession != null && !showNotifications) {
DropdownMenuItem(
text = { Text("Switch to ${if (timelineType == TimelineType.PUBLIC) "Home" else "Public"}") },
onClick = {
viewModel.switchTimeline(
if (timelineType == TimelineType.PUBLIC) TimelineType.HOME else TimelineType.PUBLIC
)
showMenu = false
},
leadingIcon = {
Icon(
if (timelineType == TimelineType.PUBLIC) Icons.Default.Home else Icons.Default.Public,
contentDescription = null
)
}
)
}
if (userSession != null) {
DropdownMenuItem(
text = { Text("Logout") },
onClick = {
onLogoutClick()
showMenu = false
},
leadingIcon = {
Icon(Icons.AutoMirrored.Filled.ExitToApp, contentDescription = null)
}
)
} else {
DropdownMenuItem(
text = { Text("Login") },
onClick = {
onLoginClick()
showMenu = false
},
leadingIcon = {
Icon(Icons.AutoMirrored.Filled.Login, contentDescription = null)
}
)
}
}
}
},
colors = TopAppBarDefaults.centerAlignedTopAppBarColors(
containerColor = MaterialTheme.colorScheme.primaryContainer,
titleContentColor = MaterialTheme.colorScheme.onPrimaryContainer
)
)
}
) { paddingValues ->
// Show status detail if selected
if (selectedStatus != null) {
// Create repository for status detail
val detailApiService = remember(userSession) {
createApiService(userSession?.instance ?: "mastodon.social", userSession?.accessToken)
}
val detailRepository = remember(detailApiService) {
MastodonRepository(detailApiService)
}
com.manalejandro.myactivitypub.ui.screens.StatusDetailScreen(
status = selectedStatus!!,
repository = detailRepository,
onBackClick = { selectedStatus = null },
onReplyClick = { replyingToStatus = it },
onStatusUpdated = { updatedStatus ->
viewModel.updateStatus(updatedStatus)
},
isAuthenticated = userSession != null
)
} else if (showNotifications && userSession != null) {
// Show notifications screen
val notificationsViewModel: com.manalejandro.myactivitypub.ui.viewmodel.NotificationsViewModel = viewModel(key = userSession.instance) {
com.manalejandro.myactivitypub.ui.viewmodel.NotificationsViewModel(
com.manalejandro.myactivitypub.data.repository.MastodonRepository(
createApiService(userSession.instance, userSession.accessToken)
)
)
}
val notificationsUiState by notificationsViewModel.uiState.collectAsState()
when (notificationsUiState) {
is com.manalejandro.myactivitypub.ui.viewmodel.NotificationsUiState.Loading -> {
Box(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator()
}
}
is com.manalejandro.myactivitypub.ui.viewmodel.NotificationsUiState.Success -> {
val notifications = (notificationsUiState as com.manalejandro.myactivitypub.ui.viewmodel.NotificationsUiState.Success).notifications
com.manalejandro.myactivitypub.ui.screens.NotificationsScreen(
notifications = notifications,
onRefresh = { notificationsViewModel.loadNotifications() },
modifier = Modifier.padding(paddingValues)
)
}
is com.manalejandro.myactivitypub.ui.viewmodel.NotificationsUiState.Error -> {
Box(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues),
contentAlignment = Alignment.Center
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text("Error loading notifications")
Button(onClick = { notificationsViewModel.loadNotifications() }) {
Text("Retry")
}
}
}
}
}
} else {
// Show timeline
when (uiState) {
is TimelineUiState.Loading -> {
Box(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues),
contentAlignment = Alignment.Center
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
CircularProgressIndicator()
Spacer(modifier = Modifier.height(16.dp))
Text(
text = "Loading ${if (timelineType == TimelineType.PUBLIC) "public" else "home"} timeline...",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
is TimelineUiState.Success -> {
val statuses = (uiState as TimelineUiState.Success).statuses
if (statuses.isEmpty()) {
Box(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues),
contentAlignment = Alignment.Center
) {
Text(
text = "No posts available",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
} else {
LazyColumn(
state = timelineScrollState,
modifier = Modifier
.fillMaxSize()
.padding(paddingValues),
contentPadding = PaddingValues(vertical = 8.dp)
) {
item {
// Timeline type indicator
Surface(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
color = MaterialTheme.colorScheme.secondaryContainer,
shape = MaterialTheme.shapes.small
) {
Row(
modifier = Modifier.padding(12.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
if (timelineType == TimelineType.PUBLIC) Icons.Default.Public else Icons.Default.Home,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSecondaryContainer
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = if (timelineType == TimelineType.PUBLIC)
"Public Federated Timeline"
else
"Home Timeline",
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSecondaryContainer
)
}
}
}
items(statuses) { status ->
StatusCard(
status = status,
onStatusClick = { selectedStatus = it },
onReplyClick = { replyingToStatus = it },
onBoostClick = { viewModel.toggleBoost(it) },
onFavoriteClick = { viewModel.toggleFavorite(it) },
isAuthenticated = userSession != null
)
}
// Loading more indicator
item {
if (isLoadingMore) {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator(
modifier = Modifier.size(32.dp)
)
}
} else if (statuses.isNotEmpty()) {
// Trigger to load more when this item becomes visible
LaunchedEffect(Unit) {
viewModel.loadMore()
}
Box(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
contentAlignment = Alignment.Center
) {
TextButton(onClick = { viewModel.loadMore() }) {
Text("Load more")
}
}
}
}
}
}
}
is TimelineUiState.Error -> {
Box(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues),
contentAlignment = Alignment.Center
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.padding(16.dp)
) {
Text(
text = "Error loading timeline",
style = MaterialTheme.typography.headlineSmall,
color = MaterialTheme.colorScheme.error
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = (uiState as TimelineUiState.Error).message,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(16.dp))
Button(onClick = { viewModel.loadTimeline() }) {
Text("Retry")
}
}
}
}
}
}
}
}

Ver fichero

@@ -0,0 +1,11 @@
package com.manalejandro.myactivitypub.data
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.preferencesDataStore
/**
* Singleton DataStore instance to prevent multiple DataStores for the same file
*/
val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "auth_prefs")

Ver fichero

@@ -0,0 +1,142 @@
package com.manalejandro.myactivitypub.data.api
import com.manalejandro.myactivitypub.data.models.*
import retrofit2.Response
import retrofit2.http.*
/**
* Mastodon API service interface
* Based on Mastodon API v1 and ActivityPub endpoints
* Reference: https://docs.joinmastodon.org/api/
*/
interface MastodonApiService {
/**
* Register an OAuth application
*/
@POST("api/v1/apps")
@FormUrlEncoded
suspend fun registerApp(
@Field("client_name") clientName: String,
@Field("redirect_uris") redirectUris: String,
@Field("scopes") scopes: String,
@Field("website") website: String? = null
): Response<AppRegistration>
/**
* Obtain OAuth token
*/
@POST("oauth/token")
@FormUrlEncoded
suspend fun obtainToken(
@Field("client_id") clientId: String,
@Field("client_secret") clientSecret: String,
@Field("redirect_uri") redirectUri: String,
@Field("grant_type") grantType: String = "authorization_code",
@Field("code") code: String,
@Field("scope") scope: String
): Response<TokenResponse>
/**
* Verify account credentials (requires authentication)
*/
@GET("api/v1/accounts/verify_credentials")
suspend fun verifyCredentials(): Response<Account>
/**
* Get public timeline
* @param limit Maximum number of statuses to return (default 20)
* @param local Show only local statuses
* @param maxId Get statuses older than this ID (for pagination)
*/
@GET("api/v1/timelines/public")
suspend fun getPublicTimeline(
@Query("limit") limit: Int = 20,
@Query("local") local: Boolean = false,
@Query("max_id") maxId: String? = null
): Response<List<Status>>
/**
* Get home timeline (requires authentication)
* @param maxId Get statuses older than this ID (for pagination)
*/
@GET("api/v1/timelines/home")
suspend fun getHomeTimeline(
@Query("limit") limit: Int = 20,
@Query("max_id") maxId: String? = null
): Response<List<Status>>
/**
* Get instance information
*/
@GET("api/v1/instance")
suspend fun getInstanceInfo(): Response<Instance>
/**
* Get account information
* @param accountId Account ID
*/
@GET("api/v1/accounts/{id}")
suspend fun getAccount(@Path("id") accountId: String): Response<Account>
/**
* Get statuses from a specific account
* @param accountId Account ID
* @param limit Maximum number of statuses to return
*/
@GET("api/v1/accounts/{id}/statuses")
suspend fun getAccountStatuses(
@Path("id") accountId: String,
@Query("limit") limit: Int = 20
): Response<List<Status>>
/**
* Favorite a status (like)
*/
@POST("api/v1/statuses/{id}/favourite")
suspend fun favoriteStatus(@Path("id") statusId: String): Response<Status>
/**
* Unfavorite a status (unlike)
*/
@POST("api/v1/statuses/{id}/unfavourite")
suspend fun unfavoriteStatus(@Path("id") statusId: String): Response<Status>
/**
* Boost a status (reblog)
*/
@POST("api/v1/statuses/{id}/reblog")
suspend fun boostStatus(@Path("id") statusId: String): Response<Status>
/**
* Unboost a status (unreblog)
*/
@POST("api/v1/statuses/{id}/unreblog")
suspend fun unboostStatus(@Path("id") statusId: String): Response<Status>
/**
* Post a new status (reply or new post)
*/
@POST("api/v1/statuses")
@FormUrlEncoded
suspend fun postStatus(
@Field("status") status: String,
@Field("in_reply_to_id") inReplyToId: String? = null,
@Field("visibility") visibility: String = "public"
): Response<Status>
/**
* Get notifications
*/
@GET("api/v1/notifications")
suspend fun getNotifications(
@Query("limit") limit: Int = 20,
@Query("exclude_types[]") excludeTypes: List<String>? = null
): Response<List<Notification>>
/**
* Get status context (ancestors and descendants/replies)
*/
@GET("api/v1/statuses/{id}/context")
suspend fun getStatusContext(@Path("id") statusId: String): Response<StatusContext>
}

Ver fichero

@@ -0,0 +1,22 @@
package com.manalejandro.myactivitypub.data.models
import com.google.gson.annotations.SerializedName
/**
* Represents a user account in Mastodon/ActivityPub
*/
data class Account(
val id: String,
val username: String,
@SerializedName("display_name") val displayName: String,
val avatar: String,
val header: String = "",
val note: String = "",
@SerializedName("followers_count") val followersCount: Int = 0,
@SerializedName("following_count") val followingCount: Int = 0,
@SerializedName("statuses_count") val statusesCount: Int = 0,
val url: String? = null,
val acct: String = username,
val bot: Boolean = false,
val locked: Boolean = false
)

Ver fichero

@@ -0,0 +1,33 @@
package com.manalejandro.myactivitypub.data.models
import com.google.gson.annotations.SerializedName
/**
* OAuth application registration response
*/
data class AppRegistration(
val id: String,
val name: String,
@SerializedName("client_id") val clientId: String,
@SerializedName("client_secret") val clientSecret: String,
@SerializedName("redirect_uri") val redirectUri: String = "myactivitypub://oauth"
)
/**
* OAuth token response
*/
data class TokenResponse(
@SerializedName("access_token") val accessToken: String,
@SerializedName("token_type") val tokenType: String,
val scope: String,
@SerializedName("created_at") val createdAt: Long
)
/**
* User session data
*/
data class UserSession(
val instance: String,
val accessToken: String,
val account: Account? = null
)

Ver fichero

@@ -0,0 +1,18 @@
package com.manalejandro.myactivitypub.data.models
import com.google.gson.annotations.SerializedName
/**
* Instance information for Mastodon/ActivityPub servers
*/
data class Instance(
val uri: String,
val title: String,
val description: String,
val version: String,
val thumbnail: String? = null,
@SerializedName("short_description") val shortDescription: String? = null,
val email: String? = null,
val languages: List<String> = emptyList(),
@SerializedName("contact_account") val contactAccount: Account? = null
)

Ver fichero

@@ -0,0 +1,15 @@
package com.manalejandro.myactivitypub.data.models
import com.google.gson.annotations.SerializedName
/**
* Represents media attachments (images, videos, etc.)
*/
data class MediaAttachment(
val id: String,
val type: String,
val url: String,
@SerializedName("preview_url") val previewUrl: String? = null,
val description: String? = null,
@SerializedName("remote_url") val remoteUrl: String? = null
)

Ver fichero

@@ -0,0 +1,14 @@
package com.manalejandro.myactivitypub.data.models
import com.google.gson.annotations.SerializedName
/**
* Represents a notification in Mastodon/ActivityPub
*/
data class Notification(
val id: String,
val type: String, // mention, reblog, favourite, follow, poll, follow_request, status, update
@SerializedName("created_at") val createdAt: String,
val account: Account,
val status: Status? = null // Present for mention, reblog, favourite, poll, status, update
)

Ver fichero

@@ -0,0 +1,23 @@
package com.manalejandro.myactivitypub.data.models
import com.google.gson.annotations.SerializedName
/**
* Represents a Mastodon/ActivityPub status (post/toot)
* Based on Mastodon API v1 specification
*/
data class Status(
val id: String,
val content: String,
@SerializedName("created_at") val createdAt: String,
val account: Account,
@SerializedName("media_attachments") val mediaAttachments: List<MediaAttachment> = emptyList(),
@SerializedName("reblog") val reblog: Status? = null,
@SerializedName("favourites_count") val favouritesCount: Int = 0,
@SerializedName("reblogs_count") val reblogsCount: Int = 0,
@SerializedName("replies_count") val repliesCount: Int = 0,
val favourited: Boolean = false,
val reblogged: Boolean = false,
val url: String? = null,
val visibility: String = "public"
)

Ver fichero

@@ -0,0 +1,9 @@
package com.manalejandro.myactivitypub.data.models
/**
* Context of a status (ancestors and descendants)
*/
data class StatusContext(
val ancestors: List<Status>, // Parent posts
val descendants: List<Status> // Replies
)

Ver fichero

@@ -0,0 +1,222 @@
package com.manalejandro.myactivitypub.data.repository
import android.content.Context
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import com.manalejandro.myactivitypub.data.api.MastodonApiService
import com.manalejandro.myactivitypub.data.dataStore
import com.manalejandro.myactivitypub.data.models.AppRegistration
import com.manalejandro.myactivitypub.data.models.TokenResponse
import com.manalejandro.myactivitypub.data.models.UserSession
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
import java.util.concurrent.TimeUnit
/**
* Repository for handling authentication operations
*/
class AuthRepository(
private val context: Context,
private val createApiService: (String) -> MastodonApiService
) {
companion object {
private val KEY_INSTANCE = stringPreferencesKey("instance")
private val KEY_ACCESS_TOKEN = stringPreferencesKey("access_token")
private val KEY_CLIENT_ID = stringPreferencesKey("client_id")
private val KEY_CLIENT_SECRET = stringPreferencesKey("client_secret")
const val REDIRECT_URI = "myactivitypub://oauth"
const val SCOPES = "read write follow"
}
/**
* Get current user session as Flow
*/
val userSession: Flow<UserSession?> = context.dataStore.data.map { prefs ->
val instance = prefs[KEY_INSTANCE]
val token = prefs[KEY_ACCESS_TOKEN]
if (instance != null && token != null) {
// Load account info with proper authentication
try {
// Create authenticated service with token
val loggingInterceptor = HttpLoggingInterceptor().apply {
level = HttpLoggingInterceptor.Level.BASIC
}
val authInterceptor = okhttp3.Interceptor { chain ->
val request = chain.request().newBuilder()
.addHeader("Authorization", "Bearer $token")
.build()
chain.proceed(request)
}
val client = OkHttpClient.Builder()
.addInterceptor(loggingInterceptor)
.addInterceptor(authInterceptor)
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.build()
val authenticatedService = Retrofit.Builder()
.baseUrl("https://$instance/")
.client(client)
.addConverterFactory(GsonConverterFactory.create())
.build()
.create(MastodonApiService::class.java)
val response = authenticatedService.verifyCredentials()
if (response.isSuccessful && response.body() != null) {
println("AuthRepository: Account loaded: @${response.body()?.username}")
UserSession(instance, token, response.body())
} else {
println("AuthRepository: Failed to load account: ${response.code()}")
UserSession(instance, token, null)
}
} catch (e: Exception) {
println("AuthRepository: Error loading account: ${e.message}")
e.printStackTrace()
UserSession(instance, token, null)
}
} else {
null
}
}.flowOn(Dispatchers.IO)
/**
* Register OAuth application with instance
*/
suspend fun registerApp(instance: String): Result<AppRegistration> {
return withContext(Dispatchers.IO) {
try {
val apiService = createApiService(instance)
val response = apiService.registerApp(
clientName = "My ActivityPub",
redirectUris = REDIRECT_URI,
scopes = SCOPES,
website = "https://github.com/yourusername/myactivitypub"
)
if (response.isSuccessful && response.body() != null) {
val app = response.body()!!
// Save app credentials
context.dataStore.edit { prefs ->
prefs[KEY_INSTANCE] = instance
prefs[KEY_CLIENT_ID] = app.clientId
prefs[KEY_CLIENT_SECRET] = app.clientSecret
}
Result.success(app)
} else {
Result.failure(Exception("Failed to register app: ${response.code()}"))
}
} catch (e: Exception) {
Result.failure(e)
}
}
}
/**
* Get OAuth authorization URL
*/
suspend fun getAuthorizationUrl(instance: String): String {
val prefs = context.dataStore.data.first()
val clientId = prefs[KEY_CLIENT_ID] ?: ""
return "https://$instance/oauth/authorize?" +
"client_id=$clientId&" +
"scope=$SCOPES&" +
"redirect_uri=$REDIRECT_URI&" +
"response_type=code"
}
/**
* Exchange authorization code for access token
*/
suspend fun obtainToken(code: String): Result<TokenResponse> {
return withContext(Dispatchers.IO) {
try {
val prefs = context.dataStore.data.first()
val instance = prefs[KEY_INSTANCE] ?: return@withContext Result.failure(
Exception("No instance configured")
)
val clientId = prefs[KEY_CLIENT_ID] ?: return@withContext Result.failure(
Exception("No client ID")
)
val clientSecret = prefs[KEY_CLIENT_SECRET] ?: return@withContext Result.failure(
Exception("No client secret")
)
println("AuthRepository: Obtaining token for instance: $instance")
val apiService = createApiService(instance)
val response = apiService.obtainToken(
clientId = clientId,
clientSecret = clientSecret,
redirectUri = REDIRECT_URI,
grantType = "authorization_code",
code = code,
scope = SCOPES
)
if (response.isSuccessful && response.body() != null) {
val token = response.body()!!
println("AuthRepository: Token obtained successfully")
// Save access token
context.dataStore.edit {
it[KEY_ACCESS_TOKEN] = token.accessToken
}
println("AuthRepository: Token saved to DataStore")
Result.success(token)
} else {
println("AuthRepository: Failed to obtain token: ${response.code()}")
Result.failure(Exception("Failed to obtain token: ${response.code()}"))
}
} catch (e: Exception) {
println("AuthRepository: Exception obtaining token: ${e.message}")
e.printStackTrace()
Result.failure(e)
}
}
}
/**
* Verify credentials and get account info
*/
suspend fun verifyCredentials(apiService: MastodonApiService): Result<UserSession> {
return withContext(Dispatchers.IO) {
try {
val response = apiService.verifyCredentials()
val prefs = context.dataStore.data.first()
val instance = prefs[KEY_INSTANCE] ?: ""
val token = prefs[KEY_ACCESS_TOKEN] ?: ""
if (response.isSuccessful && response.body() != null) {
Result.success(UserSession(instance, token, response.body()))
} else {
Result.failure(Exception("Failed to verify credentials"))
}
} catch (e: Exception) {
Result.failure(e)
}
}
}
/**
* Logout user
*/
suspend fun logout() {
context.dataStore.edit { prefs ->
prefs.remove(KEY_ACCESS_TOKEN)
prefs.remove(KEY_CLIENT_ID)
prefs.remove(KEY_CLIENT_SECRET)
prefs.remove(KEY_INSTANCE)
}
}
}

Ver fichero

@@ -0,0 +1,195 @@
package com.manalejandro.myactivitypub.data.repository
import com.manalejandro.myactivitypub.data.api.MastodonApiService
import com.manalejandro.myactivitypub.data.models.Instance
import com.manalejandro.myactivitypub.data.models.Notification
import com.manalejandro.myactivitypub.data.models.Status
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
/**
* Repository for handling Mastodon API operations
* Provides a clean API for the ViewModel layer
*/
class MastodonRepository(private val apiService: MastodonApiService) {
/**
* Fetch the public timeline (federated)
*/
suspend fun getPublicTimeline(limit: Int = 20, local: Boolean = false, maxId: String? = null): Result<List<Status>> {
return withContext(Dispatchers.IO) {
try {
val response = apiService.getPublicTimeline(limit, local, maxId)
if (response.isSuccessful) {
Result.success(response.body() ?: emptyList())
} else {
Result.failure(Exception("Error: ${response.code()} - ${response.message()}"))
}
} catch (e: Exception) {
Result.failure(e)
}
}
}
/**
* Fetch the home timeline (requires authentication)
*/
suspend fun getHomeTimeline(limit: Int = 20, maxId: String? = null): Result<List<Status>> {
return withContext(Dispatchers.IO) {
try {
val response = apiService.getHomeTimeline(limit, maxId)
if (response.isSuccessful) {
Result.success(response.body() ?: emptyList())
} else {
Result.failure(Exception("Error: ${response.code()} - ${response.message()}"))
}
} catch (e: Exception) {
Result.failure(e)
}
}
}
/**
* Fetch instance information
*/
suspend fun getInstanceInfo(): Result<Instance> {
return withContext(Dispatchers.IO) {
try {
val response = apiService.getInstanceInfo()
if (response.isSuccessful && response.body() != null) {
Result.success(response.body()!!)
} else {
Result.failure(Exception("Error: ${response.code()} - ${response.message()}"))
}
} catch (e: Exception) {
Result.failure(e)
}
}
}
/**
* Favorite a status
*/
suspend fun favoriteStatus(statusId: String): Result<Status> {
return withContext(Dispatchers.IO) {
try {
val response = apiService.favoriteStatus(statusId)
if (response.isSuccessful && response.body() != null) {
Result.success(response.body()!!)
} else {
Result.failure(Exception("Error: ${response.code()} - ${response.message()}"))
}
} catch (e: Exception) {
Result.failure(e)
}
}
}
/**
* Unfavorite a status
*/
suspend fun unfavoriteStatus(statusId: String): Result<Status> {
return withContext(Dispatchers.IO) {
try {
val response = apiService.unfavoriteStatus(statusId)
if (response.isSuccessful && response.body() != null) {
Result.success(response.body()!!)
} else {
Result.failure(Exception("Error: ${response.code()} - ${response.message()}"))
}
} catch (e: Exception) {
Result.failure(e)
}
}
}
/**
* Boost a status
*/
suspend fun boostStatus(statusId: String): Result<Status> {
return withContext(Dispatchers.IO) {
try {
val response = apiService.boostStatus(statusId)
if (response.isSuccessful && response.body() != null) {
Result.success(response.body()!!)
} else {
Result.failure(Exception("Error: ${response.code()} - ${response.message()}"))
}
} catch (e: Exception) {
Result.failure(e)
}
}
}
/**
* Unboost a status
*/
suspend fun unboostStatus(statusId: String): Result<Status> {
return withContext(Dispatchers.IO) {
try {
val response = apiService.unboostStatus(statusId)
if (response.isSuccessful && response.body() != null) {
Result.success(response.body()!!)
} else {
Result.failure(Exception("Error: ${response.code()} - ${response.message()}"))
}
} catch (e: Exception) {
Result.failure(e)
}
}
}
/**
* Post a new status or reply
*/
suspend fun postStatus(content: String, inReplyToId: String? = null): Result<Status> {
return withContext(Dispatchers.IO) {
try {
val response = apiService.postStatus(content, inReplyToId)
if (response.isSuccessful && response.body() != null) {
Result.success(response.body()!!)
} else {
Result.failure(Exception("Error: ${response.code()} - ${response.message()}"))
}
} catch (e: Exception) {
Result.failure(e)
}
}
}
/**
* Get notifications
*/
suspend fun getNotifications(limit: Int = 20): Result<List<Notification>> {
return withContext(Dispatchers.IO) {
try {
val response = apiService.getNotifications(limit)
if (response.isSuccessful) {
Result.success(response.body() ?: emptyList())
} else {
Result.failure(Exception("Error: ${response.code()} - ${response.message()}"))
}
} catch (e: Exception) {
Result.failure(e)
}
}
}
/**
* Get status context (replies)
*/
suspend fun getStatusContext(statusId: String): Result<com.manalejandro.myactivitypub.data.models.StatusContext> {
return withContext(Dispatchers.IO) {
try {
val response = apiService.getStatusContext(statusId)
if (response.isSuccessful && response.body() != null) {
Result.success(response.body()!!)
} else {
Result.failure(Exception("Error: ${response.code()} - ${response.message()}"))
}
} catch (e: Exception) {
Result.failure(e)
}
}
}
}

Ver fichero

@@ -0,0 +1,89 @@
package com.manalejandro.myactivitypub.ui.components
import androidx.compose.foundation.layout.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.manalejandro.myactivitypub.data.models.Status
/**
* Dialog for composing a reply to a status
*/
@Composable
fun ReplyDialog(
status: Status,
onDismiss: () -> Unit,
onReply: (String) -> Unit,
isPosting: Boolean = false
) {
var replyText by remember { mutableStateOf("") }
AlertDialog(
onDismissRequest = onDismiss,
title = {
Text("Reply to @${status.account.acct}")
},
text = {
Column(modifier = Modifier.fillMaxWidth()) {
// Show original toot excerpt
Text(
text = "Replying to:",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = android.text.Html.fromHtml(
status.content,
android.text.Html.FROM_HTML_MODE_COMPACT
).toString().take(100) + if (status.content.length > 100) "..." else "",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(16.dp))
// Reply text field
OutlinedTextField(
value = replyText,
onValueChange = { replyText = it },
label = { Text("Your reply") },
placeholder = { Text("Write your reply here...") },
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 100.dp),
maxLines = 5,
enabled = !isPosting
)
}
},
confirmButton = {
Button(
onClick = {
if (replyText.isNotBlank()) {
onReply(replyText)
}
},
enabled = replyText.isNotBlank() && !isPosting
) {
if (isPosting) {
CircularProgressIndicator(
modifier = Modifier.size(16.dp),
strokeWidth = 2.dp
)
} else {
Text("Reply")
}
}
},
dismissButton = {
TextButton(
onClick = onDismiss,
enabled = !isPosting
) {
Text("Cancel")
}
}
)
}

Ver fichero

@@ -0,0 +1,226 @@
package com.manalejandro.myactivitypub.ui.components
import android.text.Html
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Reply
import androidx.compose.material.icons.filled.Favorite
import androidx.compose.material.icons.filled.Repeat
import androidx.compose.material.icons.outlined.FavoriteBorder
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import coil.compose.AsyncImage
import com.manalejandro.myactivitypub.data.models.Status
import java.text.SimpleDateFormat
import java.util.*
/**
* Card component for displaying a single status/post
*/
@Composable
fun StatusCard(
status: Status,
modifier: Modifier = Modifier,
onReplyClick: (Status) -> Unit = {},
onBoostClick: (Status) -> Unit = {},
onFavoriteClick: (Status) -> Unit = {},
onStatusClick: (Status) -> Unit = {},
isAuthenticated: Boolean = false
) {
Card(
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp)
.clickable { onStatusClick(status) },
elevation = CardDefaults.cardElevation(defaultElevation = 1.dp)
) {
Column(modifier = Modifier.padding(16.dp)) {
// User info header
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
AsyncImage(
model = status.account.avatar,
contentDescription = "Avatar of ${status.account.username}",
modifier = Modifier
.size(48.dp)
.clip(CircleShape),
contentScale = ContentScale.Crop
)
Spacer(modifier = Modifier.width(12.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = status.account.displayName.ifEmpty { status.account.username },
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onSurface
)
Text(
text = "@${status.account.acct}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
// Timestamp
Text(
text = formatTimestamp(status.createdAt),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Spacer(modifier = Modifier.height(12.dp))
// Content
val htmlContent = Html.fromHtml(status.content, Html.FROM_HTML_MODE_COMPACT).toString()
Text(
text = htmlContent,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface
)
// Media attachments
if (status.mediaAttachments.isNotEmpty()) {
Spacer(modifier = Modifier.height(12.dp))
status.mediaAttachments.forEach { media ->
AsyncImage(
model = media.url,
contentDescription = media.description ?: "Media attachment",
modifier = Modifier
.fillMaxWidth()
.heightIn(max = 300.dp)
.clip(MaterialTheme.shapes.medium),
contentScale = ContentScale.Crop
)
Spacer(modifier = Modifier.height(8.dp))
}
}
Spacer(modifier = Modifier.height(12.dp))
// Action buttons
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceAround
) {
// Reply button
ActionButton(
icon = Icons.AutoMirrored.Filled.Reply,
count = status.repliesCount,
contentDescription = "Reply",
onClick = { if (isAuthenticated) onReplyClick(status) },
enabled = isAuthenticated
)
// Boost button
ActionButton(
icon = Icons.Default.Repeat,
count = status.reblogsCount,
contentDescription = "Boost",
onClick = { if (isAuthenticated) onBoostClick(status) },
isActive = status.reblogged,
enabled = isAuthenticated
)
// Favorite button
ActionButton(
icon = if (status.favourited) Icons.Filled.Favorite else Icons.Outlined.FavoriteBorder,
count = status.favouritesCount,
contentDescription = "Favorite",
onClick = { if (isAuthenticated) onFavoriteClick(status) },
isActive = status.favourited,
enabled = isAuthenticated
)
}
}
}
}
/**
* Action button for status interactions
*/
@Composable
private fun ActionButton(
icon: androidx.compose.ui.graphics.vector.ImageVector,
count: Int,
contentDescription: String,
onClick: () -> Unit,
isActive: Boolean = false,
enabled: Boolean = true
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(4.dp)
) {
IconButton(
onClick = onClick,
enabled = enabled
) {
Icon(
imageVector = icon,
contentDescription = contentDescription,
tint = when {
!enabled -> MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f)
isActive -> MaterialTheme.colorScheme.primary
else -> MaterialTheme.colorScheme.onSurfaceVariant
}
)
}
if (count > 0) {
Text(
text = formatCount(count),
style = MaterialTheme.typography.bodySmall,
color = when {
!enabled -> MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f)
isActive -> MaterialTheme.colorScheme.primary
else -> MaterialTheme.colorScheme.onSurfaceVariant
}
)
}
}
}
/**
* Format timestamp to relative time
*/
private fun formatTimestamp(timestamp: String): String {
return try {
val sdf = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.getDefault())
sdf.timeZone = TimeZone.getTimeZone("UTC")
val date = sdf.parse(timestamp)
val now = Date()
val diff = now.time - (date?.time ?: 0)
when {
diff < 60000 -> "now"
diff < 3600000 -> "${diff / 60000}m"
diff < 86400000 -> "${diff / 3600000}h"
else -> "${diff / 86400000}d"
}
} catch (e: Exception) {
""
}
}
/**
* Format large numbers with K, M suffixes
*/
private fun formatCount(count: Int): String {
return when {
count >= 1000000 -> "${count / 1000000}M"
count >= 1000 -> "${count / 1000}K"
else -> count.toString()
}
}

Ver fichero

@@ -0,0 +1,160 @@
package com.manalejandro.myactivitypub.ui.screens
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Login
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
/**
* Login screen for entering Mastodon instance
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun LoginScreen(
onLoginClick: (String) -> Unit,
onContinueAsGuest: () -> Unit,
isLoading: Boolean = false,
errorMessage: String? = null,
modifier: Modifier = Modifier
) {
var instance by remember { mutableStateOf("") }
val focusManager = LocalFocusManager.current
Scaffold(
topBar = {
TopAppBar(
title = { Text("Login to Mastodon") },
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.primaryContainer
)
)
}
) { paddingValues ->
Column(
modifier = modifier
.fillMaxSize()
.padding(paddingValues)
.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
// Logo or icon
Icon(
imageVector = Icons.AutoMirrored.Filled.Login,
contentDescription = null,
modifier = Modifier.size(80.dp),
tint = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.height(32.dp))
// Title
Text(
text = "Welcome to My ActivityPub",
style = MaterialTheme.typography.headlineMedium,
textAlign = TextAlign.Center
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "Connect to your Mastodon instance",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center
)
Spacer(modifier = Modifier.height(32.dp))
// Instance input
OutlinedTextField(
value = instance,
onValueChange = { instance = it.trim() },
label = { Text("Instance domain") },
placeholder = { Text("mastodon.social") },
singleLine = true,
enabled = !isLoading,
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Uri,
imeAction = ImeAction.Done
),
keyboardActions = KeyboardActions(
onDone = {
focusManager.clearFocus()
if (instance.isNotBlank()) {
onLoginClick(instance)
}
}
),
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "Enter just the domain (e.g., mastodon.social)",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
// Error message
if (errorMessage != null) {
Spacer(modifier = Modifier.height(16.dp))
Text(
text = errorMessage,
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodyMedium,
textAlign = TextAlign.Center
)
}
Spacer(modifier = Modifier.height(24.dp))
// Login button
Button(
onClick = { onLoginClick(instance) },
enabled = instance.isNotBlank() && !isLoading,
modifier = Modifier.fillMaxWidth()
) {
if (isLoading) {
CircularProgressIndicator(
modifier = Modifier.size(24.dp),
color = MaterialTheme.colorScheme.onPrimary
)
} else {
Text("Login")
}
}
Spacer(modifier = Modifier.height(16.dp))
// Continue as guest button
TextButton(
onClick = onContinueAsGuest,
enabled = !isLoading,
modifier = Modifier.fillMaxWidth()
) {
Text("Continue as Guest")
}
Spacer(modifier = Modifier.height(16.dp))
Text(
text = "As a guest, you can browse the public federated timeline from mastodon.social",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center
)
}
}
}

Ver fichero

@@ -0,0 +1,189 @@
package com.manalejandro.myactivitypub.ui.screens
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import coil.compose.AsyncImage
import com.manalejandro.myactivitypub.data.models.Notification
import java.text.SimpleDateFormat
import java.util.*
/**
* Screen for displaying notifications
*/
@Composable
fun NotificationsScreen(
notifications: List<Notification>,
onRefresh: () -> Unit,
modifier: Modifier = Modifier
) {
LazyColumn(
modifier = modifier.fillMaxSize(),
contentPadding = PaddingValues(vertical = 8.dp)
) {
items(notifications) { notification ->
NotificationItem(notification = notification)
HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp))
}
}
}
/**
* Single notification item
*/
@Composable
private fun NotificationItem(notification: Notification) {
Card(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
elevation = CardDefaults.cardElevation(defaultElevation = 1.dp)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalAlignment = Alignment.Top
) {
// Notification icon
Icon(
imageVector = getNotificationIcon(notification.type),
contentDescription = notification.type,
tint = getNotificationColor(notification.type),
modifier = Modifier.size(24.dp)
)
Spacer(modifier = Modifier.width(12.dp))
Column(modifier = Modifier.weight(1f)) {
// User info
Row(verticalAlignment = Alignment.CenterVertically) {
AsyncImage(
model = notification.account.avatar,
contentDescription = "Avatar",
modifier = Modifier
.size(32.dp)
.clip(CircleShape),
contentScale = ContentScale.Crop
)
Spacer(modifier = Modifier.width(8.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = notification.account.displayName.ifEmpty { notification.account.username },
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Bold
)
Text(
text = getNotificationText(notification.type),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
// Timestamp
Text(
text = formatTimestamp(notification.createdAt),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
// Status content if present
notification.status?.let { status ->
Spacer(modifier = Modifier.height(8.dp))
Surface(
modifier = Modifier.fillMaxWidth(),
color = MaterialTheme.colorScheme.surfaceVariant,
shape = MaterialTheme.shapes.small
) {
Text(
text = android.text.Html.fromHtml(
status.content,
android.text.Html.FROM_HTML_MODE_COMPACT
).toString(),
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(8.dp),
maxLines = 3
)
}
}
}
}
}
}
/**
* Get icon for notification type
*/
@Composable
private fun getNotificationIcon(type: String) = when (type) {
"mention" -> Icons.Default.AlternateEmail
"reblog" -> Icons.Default.Repeat
"favourite" -> Icons.Default.Favorite
"follow" -> Icons.Default.PersonAdd
"poll" -> Icons.Default.Poll
"status" -> Icons.Default.Campaign
else -> Icons.Default.Notifications
}
/**
* Get color for notification type
*/
@Composable
private fun getNotificationColor(type: String) = when (type) {
"mention" -> MaterialTheme.colorScheme.primary
"reblog" -> MaterialTheme.colorScheme.secondary
"favourite" -> MaterialTheme.colorScheme.error
"follow" -> MaterialTheme.colorScheme.tertiary
else -> MaterialTheme.colorScheme.onSurfaceVariant
}
/**
* Get text description for notification type
*/
private fun getNotificationText(type: String) = when (type) {
"mention" -> "mentioned you"
"reblog" -> "boosted your post"
"favourite" -> "favorited your post"
"follow" -> "followed you"
"poll" -> "poll has ended"
"status" -> "posted a new status"
"follow_request" -> "requested to follow you"
"update" -> "updated a post"
else -> type
}
/**
* Format timestamp to relative time
*/
private fun formatTimestamp(timestamp: String): String {
return try {
val sdf = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.getDefault())
sdf.timeZone = TimeZone.getTimeZone("UTC")
val date = sdf.parse(timestamp)
val now = Date()
val diff = now.time - (date?.time ?: 0)
when {
diff < 60000 -> "now"
diff < 3600000 -> "${diff / 60000}m"
diff < 86400000 -> "${diff / 3600000}h"
else -> "${diff / 86400000}d"
}
} catch (e: Exception) {
""
}
}

Ver fichero

@@ -0,0 +1,344 @@
package com.manalejandro.myactivitypub.ui.screens
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.*
import androidx.compose.material.icons.outlined.FavoriteBorder
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import coil.compose.AsyncImage
import com.manalejandro.myactivitypub.data.models.Status
import com.manalejandro.myactivitypub.data.repository.MastodonRepository
import com.manalejandro.myactivitypub.ui.viewmodel.StatusDetailViewModel
import java.text.SimpleDateFormat
import java.util.*
/**
* Screen for displaying a status detail with all its replies
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun StatusDetailScreen(
status: Status,
repository: MastodonRepository,
onBackClick: () -> Unit,
onReplyClick: (Status) -> Unit = {},
onStatusUpdated: (Status) -> Unit = {},
isAuthenticated: Boolean = false
) {
// Create ViewModel for this status
val viewModel: StatusDetailViewModel = viewModel(key = status.id) {
StatusDetailViewModel(repository, status)
}
val uiState by viewModel.uiState.collectAsState()
val currentStatus by viewModel.currentStatus.collectAsState()
val interactionState by viewModel.interactionState.collectAsState()
// Notify when status changes
LaunchedEffect(currentStatus) {
if (currentStatus.id == status.id && currentStatus != status) {
onStatusUpdated(currentStatus)
}
}
// Handle interaction state
LaunchedEffect(interactionState) {
when (interactionState) {
is com.manalejandro.myactivitypub.ui.viewmodel.StatusInteractionState.Success -> {
kotlinx.coroutines.delay(500)
viewModel.resetInteractionState()
}
else -> {}
}
}
// Handle system back button
BackHandler {
onBackClick()
}
Scaffold(
topBar = {
TopAppBar(
title = { Text("Post Detail") },
navigationIcon = {
IconButton(onClick = onBackClick) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.primaryContainer
)
)
}
) { paddingValues ->
when (uiState) {
is com.manalejandro.myactivitypub.ui.viewmodel.StatusDetailUiState.Loading -> {
Box(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator()
}
}
is com.manalejandro.myactivitypub.ui.viewmodel.StatusDetailUiState.Success -> {
val replies = (uiState as com.manalejandro.myactivitypub.ui.viewmodel.StatusDetailUiState.Success).replies
LazyColumn(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues),
contentPadding = PaddingValues(vertical = 8.dp)
) {
// Main status
item {
DetailedStatusCard(
status = currentStatus,
onReplyClick = onReplyClick,
onBoostClick = { viewModel.toggleBoost() },
onFavoriteClick = { viewModel.toggleFavorite() },
isAuthenticated = isAuthenticated
)
if (replies.isNotEmpty()) {
HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
Text(
text = "Replies (${replies.size})",
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
fontWeight = FontWeight.Bold
)
}
}
// Replies
items(replies) { reply ->
com.manalejandro.myactivitypub.ui.components.StatusCard(
status = reply,
onReplyClick = onReplyClick,
onBoostClick = { /* Individual reply boost */ },
onFavoriteClick = { /* Individual reply favorite */ },
isAuthenticated = isAuthenticated
)
}
}
}
is com.manalejandro.myactivitypub.ui.viewmodel.StatusDetailUiState.Error -> {
Box(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues),
contentAlignment = Alignment.Center
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text("Error loading replies")
Button(onClick = { viewModel.loadReplies() }) {
Text("Retry")
}
}
}
}
}
}
}
/**
* Detailed status card for the main post
*/
@Composable
private fun DetailedStatusCard(
status: Status,
onReplyClick: (Status) -> Unit,
onBoostClick: (Status) -> Unit,
onFavoriteClick: (Status) -> Unit,
isAuthenticated: Boolean
) {
Card(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
) {
// Account info
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) {
AsyncImage(
model = status.account.avatar,
contentDescription = "Avatar",
modifier = Modifier
.size(48.dp)
.clip(CircleShape),
contentScale = ContentScale.Crop
)
Spacer(modifier = Modifier.width(12.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = status.account.displayName.ifEmpty { status.account.username },
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
Text(
text = "@${status.account.acct}",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
Spacer(modifier = Modifier.height(16.dp))
// Content
Text(
text = android.text.Html.fromHtml(
status.content,
android.text.Html.FROM_HTML_MODE_COMPACT
).toString(),
style = MaterialTheme.typography.bodyLarge
)
// Media attachments
if (status.mediaAttachments.isNotEmpty()) {
Spacer(modifier = Modifier.height(12.dp))
status.mediaAttachments.forEach { media ->
if (media.type == "image") {
AsyncImage(
model = media.url,
contentDescription = media.description,
modifier = Modifier
.fillMaxWidth()
.heightIn(max = 300.dp)
.clip(MaterialTheme.shapes.medium),
contentScale = ContentScale.Crop
)
Spacer(modifier = Modifier.height(8.dp))
}
}
}
Spacer(modifier = Modifier.height(16.dp))
// Timestamp
Text(
text = formatFullTimestamp(status.createdAt),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(16.dp))
// Stats
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceEvenly
) {
StatsItem("Replies", status.repliesCount)
StatsItem("Boosts", status.reblogsCount)
StatsItem("Favorites", status.favouritesCount)
}
Spacer(modifier = Modifier.height(16.dp))
HorizontalDivider()
Spacer(modifier = Modifier.height(8.dp))
// Action buttons (same as StatusCard)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceAround
) {
TextButton(
onClick = { if (isAuthenticated) onReplyClick(status) },
enabled = isAuthenticated
) {
Icon(
Icons.Default.Reply,
contentDescription = "Reply",
modifier = Modifier.size(20.dp)
)
Spacer(modifier = Modifier.width(4.dp))
Text("Reply")
}
TextButton(
onClick = { if (isAuthenticated) onBoostClick(status) },
enabled = isAuthenticated
) {
Icon(
Icons.Default.Repeat,
contentDescription = "Boost",
modifier = Modifier.size(20.dp),
tint = if (status.reblogged) MaterialTheme.colorScheme.primary else LocalContentColor.current
)
Spacer(modifier = Modifier.width(4.dp))
Text("Boost")
}
TextButton(
onClick = { if (isAuthenticated) onFavoriteClick(status) },
enabled = isAuthenticated
) {
Icon(
if (status.favourited) Icons.Default.Favorite else Icons.Default.FavoriteBorder,
contentDescription = "Favorite",
modifier = Modifier.size(20.dp),
tint = if (status.favourited) MaterialTheme.colorScheme.error else LocalContentColor.current
)
Spacer(modifier = Modifier.width(4.dp))
Text("Favorite")
}
}
}
}
}
@Composable
private fun StatsItem(label: String, count: Int) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(
text = count.toString(),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
Text(
text = label,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
private fun formatFullTimestamp(timestamp: String): String {
return try {
val sdf = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.getDefault())
sdf.timeZone = TimeZone.getTimeZone("UTC")
val date = sdf.parse(timestamp)
val outputFormat = SimpleDateFormat("MMM dd, yyyy 'at' HH:mm", Locale.getDefault())
outputFormat.format(date ?: Date())
} catch (e: Exception) {
timestamp
}
}

Ver fichero

@@ -0,0 +1,27 @@
package com.manalejandro.myactivitypub.ui.theme
import androidx.compose.ui.graphics.Color
// Mastodon-inspired color palette
val MastodonPurple = Color(0xFF6364FF)
val MastodonPurpleLight = Color(0xFF9B9CFF)
val MastodonPurpleDark = Color(0xFF563ACC)
val LightBackground = Color(0xFFF8F8FF)
val LightSurface = Color(0xFFFFFFFF)
val DarkBackground = Color(0xFF191B22)
val DarkSurface = Color(0xFF282C37)
val AccentBlue = Color(0xFF2B90D9)
val AccentGreen = Color(0xFF79BD9A)
val AccentRed = Color(0xFFDF405A)
val Purple80 = MastodonPurpleLight
val PurpleGrey80 = Color(0xFFCCC2DC)
val Pink80 = Color(0xFFEFB8C8)
val Purple40 = MastodonPurple
val PurpleGrey40 = Color(0xFF625b71)
val Pink40 = Color(0xFF7D5260)

Ver fichero

@@ -0,0 +1,58 @@
package com.manalejandro.myactivitypub.ui.theme
import android.app.Activity
import android.os.Build
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext
private val DarkColorScheme = darkColorScheme(
primary = Purple80,
secondary = PurpleGrey80,
tertiary = Pink80
)
private val LightColorScheme = lightColorScheme(
primary = Purple40,
secondary = PurpleGrey40,
tertiary = Pink40
/* Other default colors to override
background = Color(0xFFFFFBFE),
surface = Color(0xFFFFFBFE),
onPrimary = Color.White,
onSecondary = Color.White,
onTertiary = Color.White,
onBackground = Color(0xFF1C1B1F),
onSurface = Color(0xFF1C1B1F),
*/
)
@Composable
fun MyActivityPubTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
// Dynamic color is available on Android 12+
dynamicColor: Boolean = true,
content: @Composable () -> Unit
) {
val colorScheme = when {
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
val context = LocalContext.current
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
}
darkTheme -> DarkColorScheme
else -> LightColorScheme
}
MaterialTheme(
colorScheme = colorScheme,
typography = Typography,
content = content
)
}

Ver fichero

@@ -0,0 +1,34 @@
package com.manalejandro.myactivitypub.ui.theme
import androidx.compose.material3.Typography
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
// Set of Material typography styles to start with
val Typography = Typography(
bodyLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 16.sp,
lineHeight = 24.sp,
letterSpacing = 0.5.sp
)
/* Other default text styles to override
titleLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 22.sp,
lineHeight = 28.sp,
letterSpacing = 0.sp
),
labelSmall = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Medium,
fontSize = 11.sp,
lineHeight = 16.sp,
letterSpacing = 0.5.sp
)
*/
)

Ver fichero

@@ -0,0 +1,127 @@
package com.manalejandro.myactivitypub.ui.viewmodel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.manalejandro.myactivitypub.data.models.UserSession
import com.manalejandro.myactivitypub.data.repository.AuthRepository
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
/**
* ViewModel for managing authentication state
*/
class AuthViewModel(private val authRepository: AuthRepository) : ViewModel() {
private val _authState = MutableStateFlow<AuthState>(AuthState.Loading)
val authState: StateFlow<AuthState> = _authState.asStateFlow()
val userSession: StateFlow<UserSession?> = authRepository.userSession
.stateIn(viewModelScope, SharingStarted.Eagerly, null)
init {
checkAuthStatus()
}
/**
* Check if user is authenticated
*/
private fun checkAuthStatus() {
viewModelScope.launch {
authRepository.userSession.collect { session ->
// Only update state if not in the middle of a login flow
if (_authState.value !is AuthState.LoggingIn &&
_authState.value !is AuthState.NeedsAuthorization) {
_authState.value = if (session != null) {
AuthState.Authenticated(session)
} else {
AuthState.NotAuthenticated
}
}
}
}
}
/**
* Start login flow with instance
*/
fun startLogin(instance: String) {
viewModelScope.launch {
_authState.value = AuthState.LoggingIn
authRepository.registerApp(instance).fold(
onSuccess = { app ->
val authUrl = authRepository.getAuthorizationUrl(instance)
_authState.value = AuthState.NeedsAuthorization(authUrl)
},
onFailure = { error ->
_authState.value = AuthState.Error(error.message ?: "Failed to register app")
}
)
}
}
/**
* Complete login with authorization code
*/
fun completeLogin(code: String) {
viewModelScope.launch {
println("AuthViewModel: Starting completeLogin with code: ${code.take(10)}...")
_authState.value = AuthState.LoggingIn
authRepository.obtainToken(code).fold(
onSuccess = { token ->
println("AuthViewModel: Token obtained successfully, loading user account...")
// Token saved, now load user account info
loadUserAccount()
},
onFailure = { error ->
println("AuthViewModel: Failed to complete login: ${error.message}")
_authState.value = AuthState.Error(error.message ?: "Failed to obtain token")
}
)
}
}
/**
* Load user account information
*/
private fun loadUserAccount() {
viewModelScope.launch {
// Wait a bit for the session to be available
kotlinx.coroutines.delay(100)
// The checkAuthStatus will automatically update when userSession changes
}
}
/**
* Logout current user
*/
fun logout() {
viewModelScope.launch {
authRepository.logout()
_authState.value = AuthState.NotAuthenticated
}
}
/**
* Reset error state
*/
fun resetError() {
if (_authState.value is AuthState.Error) {
_authState.value = AuthState.NotAuthenticated
}
}
}
/**
* Authentication state
*/
sealed class AuthState {
object Loading : AuthState()
object NotAuthenticated : AuthState()
object LoggingIn : AuthState()
data class NeedsAuthorization(val authUrl: String) : AuthState()
object LoginComplete : AuthState()
data class Authenticated(val session: UserSession) : AuthState()
data class Error(val message: String) : AuthState()
}

Ver fichero

@@ -0,0 +1,50 @@
package com.manalejandro.myactivitypub.ui.viewmodel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.manalejandro.myactivitypub.data.models.Notification
import com.manalejandro.myactivitypub.data.repository.MastodonRepository
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
/**
* ViewModel for managing notifications
*/
class NotificationsViewModel(private val repository: MastodonRepository) : ViewModel() {
private val _uiState = MutableStateFlow<NotificationsUiState>(NotificationsUiState.Loading)
val uiState: StateFlow<NotificationsUiState> = _uiState.asStateFlow()
init {
loadNotifications()
}
/**
* Load notifications
*/
fun loadNotifications() {
viewModelScope.launch {
_uiState.value = NotificationsUiState.Loading
repository.getNotifications().fold(
onSuccess = { notifications ->
_uiState.value = NotificationsUiState.Success(notifications)
},
onFailure = { error ->
_uiState.value = NotificationsUiState.Error(error.message ?: "Unknown error occurred")
}
)
}
}
}
/**
* UI State for the Notifications screen
*/
sealed class NotificationsUiState {
object Loading : NotificationsUiState()
data class Success(val notifications: List<Notification>) : NotificationsUiState()
data class Error(val message: String) : NotificationsUiState()
}

Ver fichero

@@ -0,0 +1,126 @@
package com.manalejandro.myactivitypub.ui.viewmodel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.manalejandro.myactivitypub.data.models.Status
import com.manalejandro.myactivitypub.data.repository.MastodonRepository
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
/**
* ViewModel for managing status detail with replies
*/
class StatusDetailViewModel(
private val repository: MastodonRepository,
private val initialStatus: Status
) : ViewModel() {
private val _uiState = MutableStateFlow<StatusDetailUiState>(StatusDetailUiState.Loading)
val uiState: StateFlow<StatusDetailUiState> = _uiState.asStateFlow()
private val _currentStatus = MutableStateFlow(initialStatus)
val currentStatus: StateFlow<Status> = _currentStatus.asStateFlow()
private val _interactionState = MutableStateFlow<StatusInteractionState>(StatusInteractionState.Idle)
val interactionState: StateFlow<StatusInteractionState> = _interactionState.asStateFlow()
init {
loadReplies()
}
/**
* Load replies for the status
*/
fun loadReplies() {
viewModelScope.launch {
_uiState.value = StatusDetailUiState.Loading
repository.getStatusContext(initialStatus.id).fold(
onSuccess = { context ->
_uiState.value = StatusDetailUiState.Success(context.descendants)
},
onFailure = { error ->
_uiState.value = StatusDetailUiState.Error(error.message ?: "Failed to load replies")
}
)
}
}
/**
* Toggle favorite on the main status
*/
fun toggleFavorite() {
viewModelScope.launch {
_interactionState.value = StatusInteractionState.Processing(_currentStatus.value.id)
val result = if (_currentStatus.value.favourited) {
repository.unfavoriteStatus(_currentStatus.value.id)
} else {
repository.favoriteStatus(_currentStatus.value.id)
}
result.fold(
onSuccess = { updatedStatus ->
_currentStatus.value = updatedStatus
_interactionState.value = StatusInteractionState.Success
},
onFailure = { error ->
_interactionState.value = StatusInteractionState.Error(error.message ?: "Failed to favorite")
}
)
}
}
/**
* Toggle boost on the main status
*/
fun toggleBoost() {
viewModelScope.launch {
_interactionState.value = StatusInteractionState.Processing(_currentStatus.value.id)
val result = if (_currentStatus.value.reblogged) {
repository.unboostStatus(_currentStatus.value.id)
} else {
repository.boostStatus(_currentStatus.value.id)
}
result.fold(
onSuccess = { updatedStatus ->
_currentStatus.value = updatedStatus
_interactionState.value = StatusInteractionState.Success
},
onFailure = { error ->
_interactionState.value = StatusInteractionState.Error(error.message ?: "Failed to boost")
}
)
}
}
/**
* Reset interaction state
*/
fun resetInteractionState() {
_interactionState.value = StatusInteractionState.Idle
}
}
/**
* UI State for the Status Detail screen
*/
sealed class StatusDetailUiState {
object Loading : StatusDetailUiState()
data class Success(val replies: List<Status>) : StatusDetailUiState()
data class Error(val message: String) : StatusDetailUiState()
}
/**
* Interaction state for status actions in detail screen
*/
sealed class StatusInteractionState {
object Idle : StatusInteractionState()
data class Processing(val statusId: String) : StatusInteractionState()
object Success : StatusInteractionState()
data class Error(val message: String) : StatusInteractionState()
}

Ver fichero

@@ -0,0 +1,224 @@
package com.manalejandro.myactivitypub.ui.viewmodel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.manalejandro.myactivitypub.data.models.Status
import com.manalejandro.myactivitypub.data.repository.MastodonRepository
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
/**
* ViewModel for managing timeline data and state
*/
class TimelineViewModel(private val repository: MastodonRepository) : ViewModel() {
private val _uiState = MutableStateFlow<TimelineUiState>(TimelineUiState.Loading)
val uiState: StateFlow<TimelineUiState> = _uiState.asStateFlow()
private val _timelineType = MutableStateFlow(TimelineType.PUBLIC)
val timelineType: StateFlow<TimelineType> = _timelineType.asStateFlow()
private val _interactionState = MutableStateFlow<InteractionState>(InteractionState.Idle)
val interactionState: StateFlow<InteractionState> = _interactionState.asStateFlow()
private val _isLoadingMore = MutableStateFlow(false)
val isLoadingMore: StateFlow<Boolean> = _isLoadingMore.asStateFlow()
private var currentStatuses = mutableListOf<Status>()
init {
loadTimeline()
}
/**
* Switch timeline type
*/
fun switchTimeline(type: TimelineType) {
_timelineType.value = type
currentStatuses.clear()
loadTimeline()
}
/**
* Load the timeline based on current type
*/
fun loadTimeline() {
viewModelScope.launch {
_uiState.value = TimelineUiState.Loading
currentStatuses.clear()
val result = when (_timelineType.value) {
TimelineType.PUBLIC -> repository.getPublicTimeline()
TimelineType.HOME -> repository.getHomeTimeline()
}
result.fold(
onSuccess = { statuses ->
currentStatuses.addAll(statuses)
_uiState.value = TimelineUiState.Success(currentStatuses.toList())
},
onFailure = { error ->
_uiState.value = TimelineUiState.Error(error.message ?: "Unknown error occurred")
}
)
}
}
/**
* Load more statuses (pagination)
*/
fun loadMore() {
if (_isLoadingMore.value || currentStatuses.isEmpty()) return
viewModelScope.launch {
_isLoadingMore.value = true
// Get the ID of the oldest status
val maxId = currentStatuses.lastOrNull()?.id
val result = when (_timelineType.value) {
TimelineType.PUBLIC -> repository.getPublicTimeline(maxId = maxId)
TimelineType.HOME -> repository.getHomeTimeline(maxId = maxId)
}
result.fold(
onSuccess = { newStatuses ->
if (newStatuses.isNotEmpty()) {
currentStatuses.addAll(newStatuses)
_uiState.value = TimelineUiState.Success(currentStatuses.toList())
}
_isLoadingMore.value = false
},
onFailure = { error ->
_isLoadingMore.value = false
// Don't change main state on pagination error, just stop loading
}
)
}
}
/**
* Toggle favorite on a status
*/
fun toggleFavorite(status: Status) {
viewModelScope.launch {
_interactionState.value = InteractionState.Processing(status.id)
val result = if (status.favourited) {
repository.unfavoriteStatus(status.id)
} else {
repository.favoriteStatus(status.id)
}
result.fold(
onSuccess = { updatedStatus ->
updateStatusInTimeline(updatedStatus)
_interactionState.value = InteractionState.Success
},
onFailure = { error ->
_interactionState.value = InteractionState.Error(error.message ?: "Failed to favorite")
}
)
}
}
/**
* Toggle boost on a status
*/
fun toggleBoost(status: Status) {
viewModelScope.launch {
_interactionState.value = InteractionState.Processing(status.id)
val result = if (status.reblogged) {
repository.unboostStatus(status.id)
} else {
repository.boostStatus(status.id)
}
result.fold(
onSuccess = { updatedStatus ->
updateStatusInTimeline(updatedStatus)
_interactionState.value = InteractionState.Success
},
onFailure = { error ->
_interactionState.value = InteractionState.Error(error.message ?: "Failed to boost")
}
)
}
}
/**
* Post a reply to a status
*/
fun postReply(content: String, inReplyToId: String) {
viewModelScope.launch {
_interactionState.value = InteractionState.Processing(inReplyToId)
repository.postStatus(content, inReplyToId).fold(
onSuccess = {
_interactionState.value = InteractionState.ReplyPosted
// Optionally reload timeline to show the new reply
},
onFailure = { error ->
_interactionState.value = InteractionState.Error(error.message ?: "Failed to post reply")
}
)
}
}
/**
* Update a status in the current timeline
*/
private fun updateStatusInTimeline(updatedStatus: Status) {
val index = currentStatuses.indexOfFirst { it.id == updatedStatus.id }
if (index != -1) {
currentStatuses[index] = updatedStatus
_uiState.value = TimelineUiState.Success(currentStatuses.toList())
}
}
/**
* Update status from external source (e.g., detail screen)
*/
fun updateStatus(updatedStatus: Status) {
updateStatusInTimeline(updatedStatus)
}
/**
* Reset interaction state
*/
fun resetInteractionState() {
_interactionState.value = InteractionState.Idle
}
}
/**
* Timeline type
*/
enum class TimelineType {
PUBLIC, // Federated public timeline
HOME // Authenticated user's home timeline
}
/**
* UI State for the Timeline screen
*/
sealed class TimelineUiState {
object Loading : TimelineUiState()
data class Success(val statuses: List<Status>) : TimelineUiState()
data class Error(val message: String) : TimelineUiState()
}
/**
* Interaction state for status actions
*/
sealed class InteractionState {
object Idle : InteractionState()
data class Processing(val statusId: String) : InteractionState()
object Success : InteractionState()
object ReplyPosted : InteractionState()
data class Error(val message: String) : InteractionState()
}

Ver fichero

@@ -0,0 +1,74 @@
<?xml version="1.0" encoding="utf-8"?>
<vector
android:height="108dp"
android:width="108dp"
android:viewportHeight="108"
android:viewportWidth="108"
xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="#3DDC84"
android:pathData="M0,0h108v108h-108z"/>
<path android:fillColor="#00000000" android:pathData="M9,0L9,108"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M19,0L19,108"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M29,0L29,108"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M39,0L39,108"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M49,0L49,108"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M59,0L59,108"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M69,0L69,108"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M79,0L79,108"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M89,0L89,108"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M99,0L99,108"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M0,9L108,9"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M0,19L108,19"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M0,29L108,29"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M0,39L108,39"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M0,49L108,49"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M0,59L108,59"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M0,69L108,69"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M0,79L108,79"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M0,89L108,89"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M0,99L108,99"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M19,29L89,29"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M19,39L89,39"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M19,49L89,49"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M19,59L89,59"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M19,69L89,69"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M19,79L89,79"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M29,19L29,89"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M39,19L39,89"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M49,19L49,89"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M59,19L59,89"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M69,19L69,89"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M79,19L79,89"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
</vector>

Ver fichero

@@ -0,0 +1,37 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<!-- Background -->
<path
android:fillColor="#2B90D9"
android:pathData="M0,0h108v108h-108z"/>
<!-- Rounded square background -->
<path
android:fillColor="#1A6DA8"
android:pathData="M24,18 Q18,18 18,24 L18,84 Q18,90 24,90 L84,90 Q90,90 90,84 L90,24 Q90,18 84,18 Z"/>
<!-- Main background -->
<path
android:fillColor="#2B90D9"
android:pathData="M26,20 Q20,20 20,26 L20,82 Q20,88 26,88 L82,88 Q88,88 88,82 L88,26 Q88,20 82,20 Z"/>
<!-- M letter -->
<path
android:fillColor="#FFFFFF"
android:pathData="M30,35 L30,73 L34,73 L34,42 L45,65 L49,65 L60,42 L60,73 L64,73 L64,35 L58,35 L47,62 L36,35 Z"/>
<!-- y letter -->
<path
android:fillColor="#FFFFFF"
android:pathData="M68,50 L68,65 L64,73 L68,73 L72,67 L76,73 L80,73 L74,64 L74,50 Z"/>
<!-- Small fediverse star -->
<path
android:fillColor="#FFFFFF"
android:fillAlpha="0.8"
android:pathData="M54,28 L55.5,32 L59.5,32 L56.5,34.5 L58,38.5 L54,36 L50,38.5 L51.5,34.5 L48.5,32 L52.5,32 Z"/>
</vector>

Ver fichero

@@ -0,0 +1,37 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<!-- Background -->
<path
android:fillColor="#2B90D9"
android:pathData="M0,0h108v108h-108z"/>
<!-- Rounded square background -->
<path
android:fillColor="#1A6DA8"
android:pathData="M24,18 Q18,18 18,24 L18,84 Q18,90 24,90 L84,90 Q90,90 90,84 L90,24 Q90,18 84,18 Z"/>
<!-- Main background -->
<path
android:fillColor="#2B90D9"
android:pathData="M26,20 Q20,20 20,26 L20,82 Q20,88 26,88 L82,88 Q88,88 88,82 L88,26 Q88,20 82,20 Z"/>
<!-- M letter -->
<path
android:fillColor="#FFFFFF"
android:pathData="M30,35 L30,73 L34,73 L34,42 L45,65 L49,65 L60,42 L60,73 L64,73 L64,35 L58,35 L47,62 L36,35 Z"/>
<!-- y letter -->
<path
android:fillColor="#FFFFFF"
android:pathData="M68,50 L68,65 L64,73 L68,73 L72,67 L76,73 L80,73 L74,64 L74,50 Z"/>
<!-- Small fediverse star -->
<path
android:fillColor="#FFFFFF"
android:fillAlpha="0.8"
android:pathData="M54,28 L55.5,32 L59.5,32 L56.5,34.5 L58,38.5 L54,36 L50,38.5 L51.5,34.5 L48.5,32 L52.5,32 Z"/>
</vector>

Ver fichero

@@ -0,0 +1,43 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="512"
android:viewportHeight="512">
<!-- Background circle -->
<path
android:fillColor="#2B90D9"
android:pathData="M256,256m-240,0a240,240 0,1 1,480 0a240,240 0,1 1,-480 0"/>
<!-- Inner glow circle -->
<path
android:fillColor="#3AA4E8"
android:fillAlpha="0.3"
android:pathData="M256,256m-220,0a220,220 0,1 1,440 0a220,220 0,1 1,-440 0"/>
<!-- Fediverse star symbol -->
<path
android:fillColor="#FFFFFF"
android:fillAlpha="0.9"
android:pathData="M256,115 L263,132 L281,132 L266,143 L272,160 L256,149 L240,160 L246,143 L231,132 L249,132 Z"/>
<!-- My -->
<path
android:fillColor="#FFFFFF"
android:pathData="M140,220 L140,340 L155,340 L155,260 L185,320 L200,320 L230,260 L230,340 L245,340 L245,220 L220,220 L192.5,290 L165,220 Z"/>
<!-- y -->
<path
android:fillColor="#FFFFFF"
android:pathData="M265,260 L265,320 L245,360 L260,360 L280,330 L300,360 L315,360 L290,315 L290,260 Z"/>
<!-- A -->
<path
android:fillColor="#FFFFFF"
android:pathData="M325,340 L340,340 L340,300 L365,300 L365,340 L380,340 L380,220 L352.5,220 L325,340 M340,260 L365,260 L365,285 L340,285 Z"/>
<!-- P -->
<path
android:fillColor="#FFFFFF"
android:pathData="M395,220 L395,340 L410,340 L410,300 L435,300 Q445,300 445,290 L445,230 Q445,220 435,220 Z M410,235 L430,235 L430,285 L410,285 Z"/>
</vector>

Ver fichero

@@ -0,0 +1,28 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="512" height="512" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg">
<!-- Background circle -->
<circle cx="256" cy="256" r="240" fill="#2B90D9"/>
<!-- Outer ring -->
<circle cx="256" cy="256" r="240" fill="none" stroke="#1A6DA8" stroke-width="8"/>
<!-- Inner glow effect -->
<circle cx="256" cy="256" r="220" fill="#3AA4E8" opacity="0.3"/>
<!-- MyAP Text -->
<text x="256" y="280"
font-family="Arial, Helvetica, sans-serif"
font-size="140"
font-weight="bold"
fill="#FFFFFF"
text-anchor="middle">
MyAP
</text>
<!-- Small ActivityPub icon/symbol (fediverse star) -->
<g transform="translate(256, 140)">
<path d="M 0,-25 L 7,-8 L 25,-8 L 10,3 L 16,20 L 0,9 L -16,20 L -10,3 L -25,-8 L -7,-8 Z"
fill="#FFFFFF"
opacity="0.9"/>
</g>
</svg>

Después

Anchura:  |  Altura:  |  Tamaño: 901 B

Ver fichero

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
</adaptive-icon>

Ver fichero

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
</adaptive-icon>

Archivo binario no mostrado.

Después

Anchura:  |  Altura:  |  Tamaño: 1.9 KiB

Archivo binario no mostrado.

Después

Anchura:  |  Altura:  |  Tamaño: 3.4 KiB

Archivo binario no mostrado.

Después

Anchura:  |  Altura:  |  Tamaño: 1.6 KiB

Archivo binario no mostrado.

Después

Anchura:  |  Altura:  |  Tamaño: 2.4 KiB

Archivo binario no mostrado.

Después

Anchura:  |  Altura:  |  Tamaño: 2.7 KiB

Archivo binario no mostrado.

Después

Anchura:  |  Altura:  |  Tamaño: 4.8 KiB

Archivo binario no mostrado.

Después

Anchura:  |  Altura:  |  Tamaño: 4.0 KiB

Archivo binario no mostrado.

Después

Anchura:  |  Altura:  |  Tamaño: 7.7 KiB

Archivo binario no mostrado.

Después

Anchura:  |  Altura:  |  Tamaño: 5.3 KiB

Archivo binario no mostrado.

Después

Anchura:  |  Altura:  |  Tamaño: 10 KiB

Ver fichero

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="purple_200">#FFBB86FC</color>
<color name="purple_500">#FF6200EE</color>
<color name="purple_700">#FF3700B3</color>
<color name="teal_200">#FF03DAC5</color>
<color name="teal_700">#FF018786</color>
<color name="black">#FF000000</color>
<color name="white">#FFFFFFFF</color>
</resources>

Ver fichero

@@ -0,0 +1,3 @@
<resources>
<string name="app_name">My ActivityPub</string>
</resources>

Ver fichero

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.MyActivityPub" parent="android:Theme.Material.Light.NoActionBar" />
</resources>

Ver fichero

@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?><!--
Sample backup rules file; uncomment and customize as necessary.
See https://developer.android.com/guide/topics/data/autobackup
for details.
Note: This file is ignored for devices older than API 31
See https://developer.android.com/about/versions/12/backup-restore
-->
<full-backup-content>
<!--
<include domain="sharedpref" path="."/>
<exclude domain="sharedpref" path="device.xml"/>
-->
</full-backup-content>

Ver fichero

@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?><!--
Sample data extraction rules file; uncomment and customize as necessary.
See https://developer.android.com/about/versions/12/backup-restore#xml-changes
for details.
-->
<data-extraction-rules>
<cloud-backup>
<!-- TODO: Use <include> and <exclude> to control what is backed up.
<include .../>
<exclude .../>
-->
</cloud-backup>
<!--
<device-transfer>
<include .../>
<exclude .../>
</device-transfer>
-->
</data-extraction-rules>

Ver fichero

@@ -0,0 +1,17 @@
package com.manalejandro.myactivitypub
import org.junit.Test
import org.junit.Assert.*
/**
* Example local unit test, which will execute on the development machine (host).
*
* See [testing documentation](http://d.android.com/tools/testing).
*/
class ExampleUnitTest {
@Test
fun addition_isCorrect() {
assertEquals(4, 2 + 2)
}
}