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

15
.gitignore vendido Archivo normal
Ver fichero

@@ -0,0 +1,15 @@
*.iml
.gradle
/local.properties
/.idea/caches
/.idea/libraries
/.idea/modules.xml
/.idea/workspace.xml
/.idea/navEditor.xml
/.idea/assetWizardSettings.xml
.DS_Store
/build
/captures
.externalNativeBuild
.cxx
local.properties

466
CONTRIBUTING.md Archivo normal
Ver fichero

@@ -0,0 +1,466 @@
# Contributing to My ActivityPub
Thank you for your interest in contributing to My ActivityPub! This document provides guidelines and instructions for contributing to the project.
## Table of Contents
- [Code of Conduct](#code-of-conduct)
- [Getting Started](#getting-started)
- [Development Setup](#development-setup)
- [How to Contribute](#how-to-contribute)
- [Coding Standards](#coding-standards)
- [Commit Guidelines](#commit-guidelines)
- [Pull Request Process](#pull-request-process)
- [Testing](#testing)
- [Documentation](#documentation)
## Code of Conduct
### Our Pledge
We are committed to providing a welcoming and inspiring community for all. Please be respectful and constructive in your interactions.
### Expected Behavior
- Use welcoming and inclusive language
- Be respectful of differing viewpoints
- Accept constructive criticism gracefully
- Focus on what is best for the community
- Show empathy towards other community members
## Getting Started
### Prerequisites
Before you begin, ensure you have:
- Android Studio Hedgehog (2023.1.1) or newer
- JDK 11 or higher
- Git installed and configured
- Basic knowledge of Kotlin and Jetpack Compose
- Familiarity with the Mastodon API
### Finding Issues to Work On
1. Check the [Issues](https://github.com/your-repo/issues) page
2. Look for issues labeled `good first issue` or `help wanted`
3. Comment on the issue to let others know you're working on it
4. Wait for maintainer approval before starting work
## Development Setup
### 1. Fork and Clone
```bash
# Fork the repository on GitHub, then clone your fork
git clone https://github.com/YOUR_USERNAME/MyActivityPub.git
cd MyActivityPub
# Add the upstream repository
git remote add upstream https://github.com/ORIGINAL_OWNER/MyActivityPub.git
```
### 2. Create a Branch
```bash
# Create a new branch for your feature or bugfix
git checkout -b feature/your-feature-name
# Or for a bugfix
git checkout -b fix/bug-description
```
### 3. Set Up the Project
```bash
# Sync Gradle files
./gradlew build
# Run the app
./gradlew installDebug
```
## How to Contribute
### Reporting Bugs
Before creating a bug report:
1. Check if the bug has already been reported
2. Verify the bug exists in the latest version
3. Collect relevant information (device, Android version, logs)
**Bug Report Template**:
```markdown
**Description**
A clear description of the bug.
**Steps to Reproduce**
1. Step one
2. Step two
3. Step three
**Expected Behavior**
What should happen.
**Actual Behavior**
What actually happens.
**Environment**
- Device: [e.g., Pixel 6]
- Android Version: [e.g., Android 13]
- App Version: [e.g., 1.0.0]
**Logs**
```
Paste relevant logs here
```
**Screenshots**
If applicable, add screenshots.
```
### Suggesting Enhancements
Enhancement suggestions are welcome! Please provide:
1. Clear description of the enhancement
2. Use cases and benefits
3. Possible implementation approach
4. Mockups or examples (if applicable)
### Contributing Code
1. **Small Changes**: Typos, bug fixes, small improvements can be submitted directly
2. **Large Changes**: Open an issue first to discuss the change
3. **New Features**: Must be discussed and approved before implementation
## Coding Standards
### Kotlin Style Guide
Follow the [Official Kotlin Coding Conventions](https://kotlinlang.org/docs/coding-conventions.html):
#### Naming
```kotlin
// Classes: PascalCase
class StatusCard { }
// Functions and variables: camelCase
fun loadTimeline() { }
val statusCount = 10
// Constants: UPPER_SNAKE_CASE
const val MAX_RETRIES = 3
// Private properties: leading underscore
private val _uiState = MutableStateFlow()
```
#### Formatting
```kotlin
// Use 4 spaces for indentation
class Example {
fun method() {
if (condition) {
// code here
}
}
}
// Line length: max 120 characters
// Break long function signatures:
fun longFunctionName(
parameter1: String,
parameter2: Int,
parameter3: Boolean
): ReturnType {
// implementation
}
```
#### Comments and Documentation
```kotlin
/**
* KDoc for public APIs
*
* @param userId The user identifier
* @return The user's timeline
*/
suspend fun getUserTimeline(userId: String): Result<List<Status>> {
// Implementation comments for complex logic
val result = apiService.getTimeline(userId)
return parseResult(result)
}
```
### Compose Best Practices
```kotlin
// Composable function names: PascalCase
@Composable
fun StatusCard(status: Status, modifier: Modifier = Modifier) {
// Always provide Modifier parameter
// Default to Modifier
}
// Extract complex composables
@Composable
private fun StatusHeader(account: Account) {
// Smaller, focused components
}
// Use remember for expensive operations
val formattedDate = remember(timestamp) {
formatDate(timestamp)
}
// Use derivedStateOf for computed values
val isExpanded by remember {
derivedStateOf { height > maxHeight }
}
```
### Architecture Guidelines
1. **Separation of Concerns**: Each class has a single responsibility
2. **MVVM Pattern**: Follow the established architecture
3. **Repository Pattern**: All data access through repositories
4. **State Management**: Use StateFlow for UI state
5. **Error Handling**: Always handle errors gracefully
### File Organization
```
app/src/main/java/com/manalejandro/myactivitypub/
├── MainActivity.kt # Entry point
├── data/
│ ├── api/ # API interfaces
│ ├── models/ # Data models
│ └── repository/ # Repository implementations
└── ui/
├── components/ # Reusable UI components
├── screens/ # Full screen composables
├── viewmodel/ # ViewModels
└── theme/ # Theme configuration
```
## Commit Guidelines
### Commit Message Format
```
<type>(<scope>): <subject>
<body>
<footer>
```
### Types
- `feat`: New feature
- `fix`: Bug fix
- `docs`: Documentation changes
- `style`: Code style changes (formatting, missing semicolons, etc.)
- `refactor`: Code refactoring
- `test`: Adding or updating tests
- `chore`: Maintenance tasks
### Examples
```bash
feat(timeline): add pull-to-refresh functionality
Implemented SwipeRefresh composable for the timeline screen.
Users can now pull down to refresh the timeline.
Closes #123
---
fix(statuscard): correct avatar image loading
Fixed issue where avatar images weren't loading correctly
due to missing Coil configuration.
Fixes #456
---
docs(readme): update installation instructions
Added more detailed steps for building the project
and troubleshooting common issues.
```
### Guidelines
- Use present tense ("add feature" not "added feature")
- Keep subject line under 50 characters
- Capitalize the subject line
- Don't end the subject line with a period
- Use the body to explain what and why, not how
- Reference issues and pull requests in the footer
## Pull Request Process
### Before Submitting
1. **Test your changes**: Ensure the app builds and runs
2. **Run lint checks**: `./gradlew lint`
3. **Update documentation**: If you changed APIs or features
4. **Add tests**: For new features or bug fixes
5. **Update CHANGELOG**: Add your changes to the unreleased section
### Submitting a Pull Request
1. **Push your branch**:
```bash
git push origin feature/your-feature-name
```
2. **Create Pull Request** on GitHub with:
- Clear title describing the change
- Detailed description of what and why
- Link to related issues
- Screenshots/recordings for UI changes
- Test instructions
3. **PR Template**:
```markdown
## Description
Brief description of changes.
## Type of Change
- [ ] Bug fix
- [ ] New feature
- [ ] Breaking change
- [ ] Documentation update
## Related Issues
Closes #123
## Testing
- [ ] Tested on physical device
- [ ] Tested on emulator
- [ ] Added unit tests
- [ ] Added UI tests
## Screenshots
[Add screenshots if applicable]
## Checklist
- [ ] Code follows style guidelines
- [ ] Self-reviewed the code
- [ ] Commented complex code
- [ ] Updated documentation
- [ ] No new warnings
- [ ] Added tests
- [ ] All tests pass
```
### Review Process
1. Maintainer will review your PR
2. Address any requested changes
3. Once approved, your PR will be merged
4. Delete your branch after merge
## Testing
### Running Tests
```bash
# Run all tests
./gradlew test
# Run unit tests
./gradlew testDebugUnitTest
# Run instrumented tests
./gradlew connectedAndroidTest
```
### Writing Tests
#### Unit Tests
```kotlin
class TimelineViewModelTest {
@Test
fun `loadTimeline updates state to Success on successful fetch`() = runTest {
// Arrange
val mockRepository = mock<MastodonRepository>()
val testStatuses = listOf(/* test data */)
whenever(mockRepository.getPublicTimeline())
.thenReturn(Result.success(testStatuses))
val viewModel = TimelineViewModel(mockRepository)
// Act
viewModel.loadTimeline()
// Assert
val state = viewModel.uiState.value
assertTrue(state is TimelineUiState.Success)
assertEquals(testStatuses, (state as TimelineUiState.Success).statuses)
}
}
```
#### Compose UI Tests
```kotlin
class StatusCardTest {
@get:Rule
val composeTestRule = createComposeRule()
@Test
fun statusCard_displays_username() {
val testStatus = Status(/* test data */)
composeTestRule.setContent {
StatusCard(status = testStatus)
}
composeTestRule
.onNodeWithText(testStatus.account.username)
.assertIsDisplayed()
}
}
```
## Documentation
### Code Documentation
- Add KDoc comments for all public APIs
- Comment complex algorithms
- Use meaningful variable and function names
- Update README.md for user-facing changes
### Documentation Files
- **README.md**: User documentation, setup, features
- **ARCHITECTURE.md**: Architecture and design decisions
- **API.md**: API integration details
- **CONTRIBUTING.md**: This file
## Questions?
If you have questions:
1. Check existing documentation
2. Search closed issues
3. Ask in discussions
4. Open a new issue with the `question` label
## Recognition
Contributors will be recognized in:
- CONTRIBUTORS.md file
- Release notes
- Project README
Thank you for contributing to My ActivityPub! 🎉

17
LICENSE Archivo normal
Ver fichero

@@ -0,0 +1,17 @@
MIT License
Copyright (c) 2026 My ActivityPub Contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

87
LOGO_README.md Archivo normal
Ver fichero

@@ -0,0 +1,87 @@
# MyActivityPub - Logo Assets
This folder contains the logo assets for the MyActivityPub application.
## Logo Versions
### 1. **logo_myap_standard.svg**
- Standard SVG logo for web, presentations, and documentation
- Size: 512x512px
- Features: Gradient background, shadow effects, fediverse star
- Color: Mastodon blue (#2B90D9)
- Best for: Marketing materials, app store graphics, website
### 2. **ic_logo_myap.xml** (Android VectorDrawable)
- Location: `app/src/main/res/drawable/ic_logo_myap.xml`
- Android vector drawable format
- Scalable for any screen density
- Best for: In-app usage, splash screen, about screen
### 3. **ic_launcher_foreground_new.xml** (Launcher Icon)
- Location: `app/src/main/res/drawable/ic_launcher_foreground_new.xml`
- Optimized for app launcher icon
- 108x108dp with safe zone
- Best for: App icon on home screen
### 4. **logo_myap.xml** (Simple SVG)
- Location: `app/src/main/res/drawable/logo_myap.xml`
- Simple SVG with text
- Best for: Quick previews
## Color Palette
- **Primary Blue**: #2B90D9 (AccentBlue from theme)
- **Dark Blue**: #1A6DA8 (Border/shadow)
- **Light Blue**: #3AA4E8 (Highlights)
- **White**: #FFFFFF (Text and icons)
## Design Elements
- **Fediverse Star**: Small star symbol representing ActivityPub/Fediverse
- **MyAP Text**: Bold, modern typography
- **Circular Background**: Professional, app-like appearance
- **Gradient Effect**: Modern, polished look
## Usage in Android
### In XML Layout
```xml
<ImageView
android:layout_width="120dp"
android:layout_height="120dp"
android:src="@drawable/ic_logo_myap"
android:contentDescription="MyActivityPub Logo" />
```
### In Compose
```kotlin
Image(
painter = painterResource(id = R.drawable.ic_logo_myap),
contentDescription = "MyActivityPub Logo",
modifier = Modifier.size(120.dp)
)
```
## Updating Launcher Icon
To use the new launcher icon:
1. Copy `ic_launcher_foreground_new.xml` content
2. Replace content in `ic_launcher_foreground.xml`
3. Rebuild the project
Or use Android Studio's Image Asset Studio:
1. Right-click `res` folder → New → Image Asset
2. Choose "Image" as Asset Type
3. Select `logo_myap_standard.svg`
4. Configure as needed
## License
These logo assets are part of the MyActivityPub project and follow the same license as the application.
## Credits
- Design: MyActivityPub Team
- Inspired by: Mastodon and Fediverse design language
- Colors: Based on Mastodon's official color palette

231
README.md Archivo normal
Ver fichero

@@ -0,0 +1,231 @@
# My ActivityPub
A modern Android client for ActivityPub and Mastodon instances built with Jetpack Compose.
## Overview
My ActivityPub is a beautiful, user-friendly Android application that allows you to interact with Mastodon and other ActivityPub-compatible social networks. The app features a clean, modern UI built entirely with Jetpack Compose and Material Design 3.
## Features
- 📱 Modern Material Design 3 UI
- 🌐 Connect to any Mastodon/ActivityPub instance
- 📰 Browse public timelines
- 🖼️ View images and media attachments
- 💬 See replies, boosts, and favorites
- 🔄 Pull to refresh timeline
- 🎨 Beautiful Mastodon-inspired color scheme
## Screenshots
(Coming soon)
## Tech Stack
- **Language**: Kotlin
- **UI Framework**: Jetpack Compose
- **Architecture**: MVVM (Model-View-ViewModel)
- **Networking**: Retrofit 2 + OkHttp
- **Image Loading**: Coil
- **Async Operations**: Kotlin Coroutines + Flow
- **Material Design**: Material 3
## Project Structure
```
app/
├── src/main/java/com/manalejandro/myactivitypub/
│ ├── MainActivity.kt # Main entry point
│ ├── data/
│ │ ├── api/
│ │ │ └── MastodonApiService.kt # API service interface
│ │ ├── models/
│ │ │ ├── Account.kt # User account model
│ │ │ ├── Status.kt # Post/status model
│ │ │ ├── MediaAttachment.kt # Media attachment model
│ │ │ └── Instance.kt # Instance information model
│ │ └── repository/
│ │ └── MastodonRepository.kt # Data repository
│ └── ui/
│ ├── components/
│ │ └── StatusCard.kt # Status card component
│ ├── viewmodel/
│ │ └── TimelineViewModel.kt # Timeline view model
│ └── theme/
│ ├── Color.kt # Color definitions
│ ├── Theme.kt # Theme configuration
│ └── Type.kt # Typography
```
## Architecture
The app follows the MVVM (Model-View-ViewModel) architecture pattern:
- **Model**: Data classes representing API responses (`Status`, `Account`, etc.)
- **View**: Composable functions for UI (`StatusCard`, `MyActivityPubApp`)
- **ViewModel**: `TimelineViewModel` manages UI state and business logic
- **Repository**: `MastodonRepository` handles data operations and API calls
### Data Flow
```
API Service → Repository → ViewModel → UI State → Composable UI
```
## Getting Started
### Prerequisites
- Android Studio Hedgehog (2023.1.1) or newer
- JDK 11 or higher
- Android SDK with API level 24+ (Android 7.0+)
### Building the Project
1. Clone the repository:
```bash
git clone <repository-url>
cd MyActivityPub
```
2. Open the project in Android Studio
3. Sync Gradle files
4. Build and run:
```bash
./gradlew assembleDebug
```
The APK will be generated at:
```
app/build/outputs/apk/debug/app-debug.apk
```
### Running on Device/Emulator
1. Connect your Android device or start an emulator
2. Click "Run" in Android Studio or use:
```bash
./gradlew installDebug
```
## API Reference
The app uses the Mastodon API v1 specification. By default, it connects to `mastodon.social`, but you can modify the base URL in `MainActivity.kt`.
### Supported Endpoints
- `GET /api/v1/timelines/public` - Fetch public timeline
- `GET /api/v1/instance` - Get instance information
- `GET /api/v1/accounts/:id` - Get account details
- `GET /api/v1/accounts/:id/statuses` - Get account statuses
For more information, see the [Mastodon API documentation](https://docs.joinmastodon.org/api/).
## Configuration
### Changing the Instance
To connect to a different Mastodon instance, modify the base URL in `MainActivity.kt`:
```kotlin
val retrofit = Retrofit.Builder()
.baseUrl("https://your-instance.social/")
// ...
```
### Gradle Configuration
Key configuration files:
- `build.gradle.kts` - App dependencies and build configuration
- `gradle.properties` - Gradle properties (JVM memory, etc.)
- `libs.versions.toml` - Version catalog for dependencies
## Dependencies
| Library | Version | Purpose |
|---------|---------|---------|
| Retrofit | 2.9.0 | REST API client |
| OkHttp | 4.12.0 | HTTP client |
| Gson | 2.9.0 | JSON serialization |
| Coil | 2.5.0 | Image loading |
| Coroutines | 1.7.3 | Async programming |
| Compose BOM | 2024.09.00 | Jetpack Compose libraries |
| Material 3 | Latest | Material Design 3 components |
## Development
### Code Style
This project follows the official Kotlin coding conventions. Key guidelines:
- Use 4 spaces for indentation
- Use camelCase for variables and functions
- Use PascalCase for classes
- Add KDoc comments for public APIs
- Keep functions small and focused
### Adding New Features
1. Create data models in `data/models/`
2. Add API endpoints in `MastodonApiService.kt`
3. Implement repository methods in `MastodonRepository.kt`
4. Create ViewModel with UI state in `ui/viewmodel/`
5. Build UI components in `ui/components/`
6. Wire everything together in composable screens
## Troubleshooting
### Build Issues
**Gradle daemon crashes:**
- Increase JVM memory in `gradle.properties`: `org.gradle.jvmargs=-Xmx4096m`
**Dependency resolution fails:**
- Clear Gradle cache: `./gradlew clean --no-daemon`
- Invalidate caches in Android Studio
**Compose compiler issues:**
- Ensure Kotlin and Compose versions are compatible
- Check `libs.versions.toml` for version alignment
### Runtime Issues
**Network errors:**
- Verify `INTERNET` permission in `AndroidManifest.xml`
- Check device/emulator internet connection
- Ensure HTTPS URLs are used (clear text traffic is disabled)
**Image loading failures:**
- Coil requires valid URLs
- Check LogCat for detailed error messages
## Contributing
Contributions are welcome! Please follow these steps:
1. Fork the repository
2. Create a feature branch (`git checkout -b feature/amazing-feature`)
3. Commit your changes (`git commit -m 'Add amazing feature'`)
4. Push to the branch (`git push origin feature/amazing-feature`)
5. Open a Pull Request
## License
This project is licensed under the MIT License - see the LICENSE file for details.
## Acknowledgments
- [Mastodon](https://joinmastodon.org/) for the API specification
- [Takahe](https://github.com/jointakahe/takahe) for API reference
- Material Design 3 for the beautiful design system
- The Android and Kotlin communities
## Contact
For questions or feedback, please open an issue on GitHub.
---
**Note**: This is a demonstration project showcasing modern Android development with Jetpack Compose. For production use, consider adding authentication, error handling improvements, and additional features like posting, following, and notifications.

1
app/.gitignore vendido Archivo normal
Ver fichero

@@ -0,0 +1 @@
/build

82
app/build.gradle.kts Archivo normal
Ver fichero

@@ -0,0 +1,82 @@
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.compose)
id("kotlin-parcelize")
}
android {
namespace = "com.manalejandro.myactivitypub"
compileSdk = 35
defaultConfig {
applicationId = "com.manalejandro.myactivitypub"
minSdk = 24
targetSdk = 35
versionCode = 1
versionName = "1.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
buildTypes {
release {
isMinifyEnabled = false
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
}
buildFeatures {
compose = true
}
}
dependencies {
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.activity.compose)
implementation(platform(libs.androidx.compose.bom))
implementation(libs.androidx.compose.ui)
implementation(libs.androidx.compose.ui.graphics)
implementation(libs.androidx.compose.ui.tooling.preview)
implementation(libs.androidx.compose.material3)
// Navigation
implementation("androidx.navigation:navigation-compose:2.7.7")
// Retrofit for API calls
implementation("com.squareup.retrofit2:retrofit:2.9.0")
implementation("com.squareup.retrofit2:converter-gson:2.9.0")
implementation("com.squareup.okhttp3:logging-interceptor:4.12.0")
// Coil for image loading
implementation("io.coil-kt:coil-compose:2.5.0")
// ViewModel
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.7.0")
// Coroutines
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3")
// Icons extended
implementation("androidx.compose.material:material-icons-extended:1.6.0")
// DataStore for preferences
implementation("androidx.datastore:datastore-preferences:1.0.0")
// Browser for OAuth
implementation("androidx.browser:browser:1.7.0")
testImplementation(libs.junit)
androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core)
androidTestImplementation(platform(libs.androidx.compose.bom))
androidTestImplementation(libs.androidx.compose.ui.test.junit4)
debugImplementation(libs.androidx.compose.ui.tooling)
debugImplementation(libs.androidx.compose.ui.test.manifest)
}

21
app/proguard-rules.pro vendido Archivo normal
Ver fichero

@@ -0,0 +1,21 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile

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)
}
}

5
build.gradle.kts Archivo normal
Ver fichero

@@ -0,0 +1,5 @@
// Top-level build file where you can add configuration options common to all sub-projects/modules.
plugins {
alias(libs.plugins.android.application) apply false
alias(libs.plugins.kotlin.compose) apply false
}

46
build_output.log Archivo normal
Ver fichero

@@ -0,0 +1,46 @@
Starting a Gradle Daemon, 2 stopped Daemons could not be reused, use --status for details
> Task :app:preBuild UP-TO-DATE
> Task :app:preDebugBuild UP-TO-DATE
> Task :app:mergeDebugNativeDebugMetadata NO-SOURCE
> Task :app:generateDebugResources UP-TO-DATE
> Task :app:packageDebugResources UP-TO-DATE
> Task :app:processDebugNavigationResources UP-TO-DATE
> Task :app:parseDebugLocalResources UP-TO-DATE
> Task :app:generateDebugRFile UP-TO-DATE
> Task :app:compileDebugKotlin UP-TO-DATE
> Task :app:javaPreCompileDebug UP-TO-DATE
> Task :app:compileDebugJavaWithJavac NO-SOURCE
> Task :app:generateDebugAssets UP-TO-DATE
> Task :app:mergeDebugAssets UP-TO-DATE
> Task :app:compressDebugAssets UP-TO-DATE
> Task :app:processDebugJavaRes UP-TO-DATE
> Task :app:mergeDebugJavaResource UP-TO-DATE
> Task :app:checkDebugDuplicateClasses UP-TO-DATE
> Task :app:desugarDebugFileDependencies UP-TO-DATE
> Task :app:mergeExtDexDebug UP-TO-DATE
> Task :app:mergeLibDexDebug UP-TO-DATE
> Task :app:checkDebugAarMetadata UP-TO-DATE
> Task :app:compileDebugNavigationResources UP-TO-DATE
> Task :app:mapDebugSourceSetPaths UP-TO-DATE
> Task :app:mergeDebugResources UP-TO-DATE
> Task :app:createDebugCompatibleScreenManifests UP-TO-DATE
> Task :app:extractDeepLinksDebug UP-TO-DATE
> Task :app:processDebugMainManifest UP-TO-DATE
> Task :app:processDebugManifest UP-TO-DATE
> Task :app:processDebugManifestForPackage UP-TO-DATE
> Task :app:processDebugResources UP-TO-DATE
> Task :app:dexBuilderDebug UP-TO-DATE
> Task :app:mergeProjectDexDebug UP-TO-DATE
> Task :app:mergeDebugJniLibFolders UP-TO-DATE
> Task :app:mergeDebugNativeLibs UP-TO-DATE
> Task :app:stripDebugDebugSymbols UP-TO-DATE
> Task :app:validateSigningDebug UP-TO-DATE
> Task :app:writeDebugAppMetadata UP-TO-DATE
> Task :app:writeDebugSigningConfigVersions UP-TO-DATE
> Task :app:packageDebug UP-TO-DATE
> Task :app:createDebugApkListingFileRedirect UP-TO-DATE
> Task :app:assembleDebug UP-TO-DATE
BUILD SUCCESSFUL in 12s
35 actionable tasks: 35 up-to-date
Consider enabling configuration cache to speed up this build: https://docs.gradle.org/9.1.0/userguide/configuration_cache_enabling.html

337
docs/API.md Archivo normal
Ver fichero

@@ -0,0 +1,337 @@
# API Documentation
## Overview
This document describes the Mastodon API integration used in My ActivityPub app. The app communicates with Mastodon-compatible servers using their REST API v1.
## Base URL
Default: `https://mastodon.social/`
You can configure this to any Mastodon or ActivityPub-compatible instance.
## Authentication
Currently, the app accesses public endpoints that don't require authentication. Future versions will implement OAuth 2.0 for authenticated requests.
## Endpoints
### 1. Get Public Timeline
Retrieve statuses from the public timeline.
**Endpoint**: `GET /api/v1/timelines/public`
**Parameters**:
- `limit` (integer, optional): Maximum number of results. Default: 20
- `local` (boolean, optional): Show only local statuses. Default: false
- `max_id` (string, optional): Return results older than this ID
- `since_id` (string, optional): Return results newer than this ID
- `min_id` (string, optional): Return results immediately newer than this ID
**Example Request**:
```
GET https://mastodon.social/api/v1/timelines/public?limit=20&local=false
```
**Response**: Array of Status objects
**Example Response**:
```json
[
{
"id": "109382159165398564",
"created_at": "2022-11-23T07:49:01.940Z",
"content": "<p>Hello world!</p>",
"account": {
"id": "109382",
"username": "alice",
"display_name": "Alice",
"avatar": "https://...",
...
},
"media_attachments": [],
"favourites_count": 5,
"reblogs_count": 2,
"replies_count": 1,
...
}
]
```
### 2. Get Instance Information
Get information about the Mastodon instance.
**Endpoint**: `GET /api/v1/instance`
**Parameters**: None
**Example Request**:
```
GET https://mastodon.social/api/v1/instance
```
**Response**: Instance object
**Example Response**:
```json
{
"uri": "mastodon.social",
"title": "Mastodon",
"short_description": "The original server operated by the Mastodon gGmbH non-profit",
"description": "...",
"version": "4.0.0",
"languages": ["en"],
"thumbnail": "https://..."
}
```
### 3. Get Account
Get account information.
**Endpoint**: `GET /api/v1/accounts/:id`
**Parameters**:
- `id` (string, required): Account ID
**Example Request**:
```
GET https://mastodon.social/api/v1/accounts/109382
```
**Response**: Account object
**Example Response**:
```json
{
"id": "109382",
"username": "alice",
"acct": "alice",
"display_name": "Alice",
"locked": false,
"bot": false,
"created_at": "2022-11-23T07:49:01.940Z",
"note": "<p>Bio goes here</p>",
"url": "https://mastodon.social/@alice",
"avatar": "https://...",
"header": "https://...",
"followers_count": 100,
"following_count": 50,
"statuses_count": 500
}
```
### 4. Get Account Statuses
Get statuses posted by an account.
**Endpoint**: `GET /api/v1/accounts/:id/statuses`
**Parameters**:
- `id` (string, required): Account ID
- `limit` (integer, optional): Maximum number of results. Default: 20
- `max_id` (string, optional): Return results older than this ID
- `since_id` (string, optional): Return results newer than this ID
- `exclude_replies` (boolean, optional): Skip statuses that reply to other statuses
- `exclude_reblogs` (boolean, optional): Skip statuses that are reblogs of other statuses
- `only_media` (boolean, optional): Show only statuses with media attached
**Example Request**:
```
GET https://mastodon.social/api/v1/accounts/109382/statuses?limit=20
```
**Response**: Array of Status objects
## Data Models
### Status
Represents a post/toot on Mastodon.
**Properties**:
```typescript
{
id: string // Unique identifier
created_at: string // ISO 8601 datetime
content: string // HTML content
account: Account // Account that posted this status
media_attachments: Array // Media attachments
reblog: Status | null // If this is a reblog, the original status
favourites_count: number // Number of favorites
reblogs_count: number // Number of reblogs/boosts
replies_count: number // Number of replies
favourited: boolean // Has the current user favorited this?
reblogged: boolean // Has the current user reblogged this?
url: string | null // URL to the status
visibility: string // Visibility level (public, unlisted, private, direct)
}
```
### Account
Represents a user account.
**Properties**:
```typescript
{
id: string // Unique identifier
username: string // Username (without @domain)
acct: string // Full username (@username@domain)
display_name: string // Display name
avatar: string // URL to avatar image
header: string // URL to header image
note: string // Bio/description (HTML)
url: string | null // URL to profile page
followers_count: number // Number of followers
following_count: number // Number of accounts being followed
statuses_count: number // Number of statuses posted
bot: boolean // Is this a bot account?
locked: boolean // Does this account require follow requests?
}
```
### MediaAttachment
Represents media files attached to statuses.
**Properties**:
```typescript
{
id: string // Unique identifier
type: string // Type: image, video, gifv, audio, unknown
url: string // URL to the media file
preview_url: string | null // URL to the preview/thumbnail
remote_url: string | null // Remote URL if the media is from another server
description: string | null // Alt text description
}
```
### Instance
Represents a Mastodon instance.
**Properties**:
```typescript
{
uri: string // Domain name
title: string // Instance name
description: string // Long description (HTML)
short_description: string // Short description (plaintext)
version: string // Mastodon version
thumbnail: string | null // URL to thumbnail image
languages: Array<string> // ISO 639 Part 1-5 language codes
email: string | null // Contact email
contact_account: Account // Contact account
}
```
## Error Handling
The API returns standard HTTP status codes:
- `200 OK` - Request succeeded
- `400 Bad Request` - Invalid parameters
- `401 Unauthorized` - Authentication required
- `403 Forbidden` - Access denied
- `404 Not Found` - Resource not found
- `429 Too Many Requests` - Rate limit exceeded
- `500 Internal Server Error` - Server error
- `503 Service Unavailable` - Server temporarily unavailable
**Error Response Format**:
```json
{
"error": "Error message here"
}
```
## Rate Limiting
Mastodon implements rate limiting to prevent abuse. Limits vary by instance but typically:
- 300 requests per 5 minutes for authenticated requests
- Lower limits for unauthenticated requests
Rate limit information is returned in response headers:
- `X-RateLimit-Limit` - Maximum number of requests
- `X-RateLimit-Remaining` - Remaining requests in current window
- `X-RateLimit-Reset` - Time when the limit resets (ISO 8601)
## Best Practices
1. **Respect Rate Limits**: Implement exponential backoff when hitting rate limits
2. **Cache Responses**: Cache instance info and other static data
3. **Use Pagination**: Use `max_id` and `since_id` for efficient pagination
4. **Handle Errors**: Always handle network errors and API errors gracefully
5. **Validate Input**: Validate user input before making API calls
6. **Use HTTPS**: Always use HTTPS for API requests
7. **Set User-Agent**: Include a descriptive User-Agent header
## Implementation in the App
### Service Layer
`MastodonApiService.kt` defines the API interface using Retrofit annotations:
```kotlin
interface MastodonApiService {
@GET("api/v1/timelines/public")
suspend fun getPublicTimeline(
@Query("limit") limit: Int = 20,
@Query("local") local: Boolean = false
): Response<List<Status>>
// Other endpoints...
}
```
### Repository Layer
`MastodonRepository.kt` wraps API calls with error handling:
```kotlin
suspend fun getPublicTimeline(limit: Int = 20, local: Boolean = false): Result<List<Status>> {
return withContext(Dispatchers.IO) {
try {
val response = apiService.getPublicTimeline(limit, local)
if (response.isSuccessful) {
Result.success(response.body() ?: emptyList())
} else {
Result.failure(Exception("Error: ${response.code()}"))
}
} catch (e: Exception) {
Result.failure(e)
}
}
}
```
### ViewModel Layer
`TimelineViewModel.kt` manages UI state and calls repository methods:
```kotlin
fun loadTimeline() {
viewModelScope.launch {
_uiState.value = TimelineUiState.Loading
repository.getPublicTimeline().fold(
onSuccess = { statuses ->
_uiState.value = TimelineUiState.Success(statuses)
},
onFailure = { error ->
_uiState.value = TimelineUiState.Error(error.message ?: "Unknown error")
}
)
}
}
```
## Further Reading
- [Official Mastodon API Documentation](https://docs.joinmastodon.org/api/)
- [ActivityPub Specification](https://www.w3.org/TR/activitypub/)
- [Retrofit Documentation](https://square.github.io/retrofit/)
- [Kotlin Coroutines Guide](https://kotlinlang.org/docs/coroutines-guide.html)

476
docs/ARCHITECTURE.md Archivo normal
Ver fichero

@@ -0,0 +1,476 @@
# Architecture Documentation
## Overview
My ActivityPub follows the **MVVM (Model-View-ViewModel)** architecture pattern combined with **Repository pattern** for clean separation of concerns and testability.
## Architecture Layers
```
┌─────────────────────────────────────────────────┐
│ UI Layer (View) │
│ Jetpack Compose Composables │
└──────────────────┬──────────────────────────────┘
│ observes StateFlow
┌─────────────────────────────────────────────────┐
│ ViewModel Layer │
│ Business Logic & State Management │
└──────────────────┬──────────────────────────────┘
│ calls methods
┌─────────────────────────────────────────────────┐
│ Repository Layer │
│ Data Operations Abstraction │
└──────────────────┬──────────────────────────────┘
│ makes API calls
┌─────────────────────────────────────────────────┐
│ Data Source Layer │
│ API Service (Retrofit) │
└──────────────────┬──────────────────────────────┘
│ HTTP requests
┌─────────────────────────────────────────────────┐
│ Remote Server │
│ Mastodon/ActivityPub API │
└─────────────────────────────────────────────────┘
```
## Layer Details
### 1. UI Layer (View)
**Location**: `ui/components/`, `MainActivity.kt`
**Responsibilities**:
- Display data to the user
- Capture user interactions
- Observe ViewModel state
- Render UI using Jetpack Compose
**Key Components**:
#### MainActivity.kt
The entry point of the application. Sets up the Compose UI, dependency injection (manual), and hosts the main composable.
```kotlin
@Composable
fun MyActivityPubApp() {
// Setup dependencies
val repository = MastodonRepository(apiService)
val viewModel: TimelineViewModel = viewModel { TimelineViewModel(repository) }
// Observe state
val uiState by viewModel.uiState.collectAsState()
// Render UI based on state
when (uiState) {
is TimelineUiState.Loading -> LoadingUI()
is TimelineUiState.Success -> TimelineUI(statuses)
is TimelineUiState.Error -> ErrorUI(message)
}
}
```
#### StatusCard.kt
Reusable composable component for displaying individual status posts.
**Features**:
- User avatar and information
- HTML content rendering
- Media attachment display
- Interaction buttons (reply, boost, favorite)
- Timestamp formatting
### 2. ViewModel Layer
**Location**: `ui/viewmodel/`
**Responsibilities**:
- Manage UI state
- Handle business logic
- Coordinate data operations
- Expose data streams to UI
- Survive configuration changes
**Key Components**:
#### TimelineViewModel.kt
Manages the timeline screen state and operations.
**State Management**:
```kotlin
sealed class TimelineUiState {
object Loading : TimelineUiState()
data class Success(val statuses: List<Status>) : TimelineUiState()
data class Error(val message: String) : TimelineUiState()
}
```
**Flow**:
1. UI subscribes to `uiState` StateFlow
2. ViewModel fetches data from repository
3. ViewModel updates state based on result
4. UI recomposes with new state
**Benefits**:
- Survives configuration changes (screen rotation)
- Separates UI logic from business logic
- Makes testing easier
- Provides lifecycle awareness
### 3. Repository Layer
**Location**: `data/repository/`
**Responsibilities**:
- Abstract data sources
- Provide clean API to ViewModel
- Handle data operations
- Implement error handling
- Coordinate multiple data sources (if needed)
**Key Components**:
#### MastodonRepository.kt
Provides data operations for Mastodon API.
**Methods**:
- `getPublicTimeline()`: Fetch public timeline
- `getInstanceInfo()`: Get instance information
**Pattern**:
```kotlin
suspend fun getPublicTimeline(limit: Int, local: Boolean): Result<List<Status>> {
return withContext(Dispatchers.IO) {
try {
val response = apiService.getPublicTimeline(limit, local)
if (response.isSuccessful) {
Result.success(response.body() ?: emptyList())
} else {
Result.failure(Exception("Error: ${response.code()}"))
}
} catch (e: Exception) {
Result.failure(e)
}
}
}
```
**Benefits**:
- Single source of truth
- Easy to test with mock data
- Can add caching layer
- Switches between local/remote data
### 4. Data Source Layer
**Location**: `data/api/`, `data/models/`
**Responsibilities**:
- Define API endpoints
- Make network requests
- Parse JSON responses
- Define data models
**Key Components**:
#### MastodonApiService.kt
Retrofit interface defining API endpoints.
```kotlin
interface MastodonApiService {
@GET("api/v1/timelines/public")
suspend fun getPublicTimeline(
@Query("limit") limit: Int = 20,
@Query("local") local: Boolean = false
): Response<List<Status>>
}
```
#### Data Models
**Status.kt**: Represents a post/toot
**Account.kt**: Represents a user account
**MediaAttachment.kt**: Represents media files
**Instance.kt**: Represents server instance info
## Data Flow
### Loading Timeline Example
```
1. User opens app
2. MainActivity creates ViewModel
3. ViewModel initializes and calls loadTimeline()
4. ViewModel sets state to Loading
5. UI shows loading indicator
6. ViewModel calls repository.getPublicTimeline()
7. Repository calls apiService.getPublicTimeline()
8. Retrofit makes HTTP request to Mastodon API
9. API returns JSON response
10. Gson parses JSON to List<Status>
11. Repository returns Result.success(statuses)
12. ViewModel updates state to Success(statuses)
13. UI recomposes with status list
14. StatusCard components render each status
```
### Error Handling Flow
```
Network Error occurs
Repository catches exception
Returns Result.failure(exception)
ViewModel updates state to Error(message)
UI shows error message and retry button
User clicks retry
Flow starts again from step 3
```
## State Management
### UI State Pattern
The app uses **unidirectional data flow** with sealed classes for state:
```kotlin
sealed class TimelineUiState {
object Loading : TimelineUiState()
data class Success(val statuses: List<Status>) : TimelineUiState()
data class Error(val message: String) : TimelineUiState()
}
```
**Benefits**:
- Type-safe state representation
- Exhaustive when expressions
- Clear state transitions
- Easy to add new states
### StateFlow vs LiveData
The app uses **StateFlow** instead of LiveData because:
- Better Kotlin coroutines integration
- More powerful operators
- Lifecycle-aware collection in Compose
- Can be used outside Android
## Dependency Management
### Current Implementation
The app uses **manual dependency injection** in `MainActivity`:
```kotlin
val client = OkHttpClient.Builder()
.addInterceptor(loggingInterceptor)
.build()
val retrofit = Retrofit.Builder()
.baseUrl("https://mastodon.social/")
.client(client)
.addConverterFactory(GsonConverterFactory.create())
.build()
val apiService = retrofit.create(MastodonApiService::class.java)
val repository = MastodonRepository(apiService)
val viewModel = TimelineViewModel(repository)
```
### Future Improvements
For larger apps, consider:
- **Hilt**: Dependency injection framework
- **Koin**: Lightweight DI for Kotlin
- **Manual DI with modules**: Create separate DI container classes
## Threading Model
### Coroutines and Dispatchers
The app uses Kotlin coroutines for async operations:
**Dispatchers.Main**: UI operations
```kotlin
viewModelScope.launch { // Main by default
_uiState.value = TimelineUiState.Loading
}
```
**Dispatchers.IO**: Network and disk I/O
```kotlin
withContext(Dispatchers.IO) {
apiService.getPublicTimeline()
}
```
**Dispatcher.Default**: CPU-intensive work
```kotlin
withContext(Dispatchers.Default) {
// Heavy computation
}
```
## Error Handling Strategy
### Result Pattern
The repository uses Kotlin's `Result` type:
```kotlin
suspend fun getPublicTimeline(): Result<List<Status>> {
return try {
Result.success(data)
} catch (e: Exception) {
Result.failure(e)
}
}
```
**Consumption in ViewModel**:
```kotlin
repository.getPublicTimeline().fold(
onSuccess = { data -> /* handle success */ },
onFailure = { error -> /* handle error */ }
)
```
### Error Types
1. **Network Errors**: No internet, timeout
2. **HTTP Errors**: 4xx, 5xx status codes
3. **Parse Errors**: Invalid JSON
4. **Unknown Errors**: Unexpected exceptions
## Testing Strategy
### Unit Testing
**Repository Layer**:
```kotlin
@Test
fun `getPublicTimeline returns success with valid data`() = runTest {
val mockApi = mock<MastodonApiService>()
val repository = MastodonRepository(mockApi)
// Test implementation
}
```
**ViewModel Layer**:
```kotlin
@Test
fun `loadTimeline updates state to Success`() = runTest {
val mockRepository = mock<MastodonRepository>()
val viewModel = TimelineViewModel(mockRepository)
// Test implementation
}
```
### Integration Testing
Test complete flows from ViewModel to Repository to API.
### UI Testing
Use Compose Testing to test UI components:
```kotlin
@Test
fun statusCard_displays_content() {
composeTestRule.setContent {
StatusCard(status = testStatus)
}
composeTestRule
.onNodeWithText("Test content")
.assertIsDisplayed()
}
```
## Performance Considerations
### Image Loading
**Coil** library handles:
- Async loading
- Caching (memory + disk)
- Placeholder/error images
- Automatic lifecycle management
### List Performance
**LazyColumn** provides:
- Lazy loading (only visible items rendered)
- Recycling (item reuse)
- Efficient scrolling
### Memory Management
- ViewModels survive configuration changes
- Images are cached efficiently
- Lists use lazy loading
## Security Considerations
1. **HTTPS Only**: All API calls use HTTPS
2. **No Cleartext Traffic**: Disabled in manifest
3. **Input Validation**: Validate all user input
4. **Rate Limiting**: Respect API rate limits
5. **Error Messages**: Don't expose sensitive info in errors
## Future Enhancements
### Recommended Additions
1. **Authentication**: OAuth 2.0 implementation
2. **Database**: Room for offline caching
3. **Pagination**: Implement infinite scroll
4. **Deep Linking**: Handle Mastodon URLs
5. **Search**: Add search functionality
6. **Notifications**: Push notifications support
7. **Multiple Accounts**: Account switching
8. **Dark Theme**: Complete theme support
9. **Accessibility**: Enhanced accessibility features
10. **Testing**: Comprehensive test coverage
### Architecture Evolution
As the app grows:
- Move to multi-module architecture
- Implement Clean Architecture layers
- Add use cases/interactors
- Separate feature modules
- Add shared UI components module
## References
- [Guide to app architecture (Android)](https://developer.android.com/topic/architecture)
- [ViewModel Overview](https://developer.android.com/topic/libraries/architecture/viewmodel)
- [Repository Pattern](https://developer.android.com/codelabs/basic-android-kotlin-training-repository-pattern)
- [Kotlin Coroutines](https://kotlinlang.org/docs/coroutines-overview.html)
- [Jetpack Compose Architecture](https://developer.android.com/jetpack/compose/architecture)

153
docs/DOCUMENTATION_INDEX.md Archivo normal
Ver fichero

@@ -0,0 +1,153 @@
# Documentation Index
Welcome to the My ActivityPub documentation! This index will help you find the information you need.
## 📚 Documentation Files
### Getting Started
1. **[README.md](../README.md)** - Project overview, features, and quick start guide
- What is My ActivityPub?
- Features and screenshots
- Tech stack overview
- Quick installation instructions
2. **[SETUP.md](SETUP.md)** - Complete development environment setup
- Prerequisites and required software
- Step-by-step project setup
- Building and running the app
- Troubleshooting common issues
- IDE configuration tips
### Development
3. **[ARCHITECTURE.md](ARCHITECTURE.md)** - Application architecture and design
- MVVM architecture explained
- Layer responsibilities
- Data flow diagrams
- State management patterns
- Threading model with coroutines
- Testing strategies
4. **[API.md](API.md)** - Mastodon API integration documentation
- API endpoints used
- Request/response examples
- Data models explained
- Error handling
- Rate limiting
- Best practices
5. **[CONTRIBUTING.md](../CONTRIBUTING.md)** - How to contribute to the project
- Code of conduct
- Development workflow
- Coding standards
- Commit message guidelines
- Pull request process
- Testing requirements
### Legal
6. **[LICENSE](../LICENSE)** - MIT License
- Copyright information
- Terms and conditions
- Usage rights
## 🎯 Quick Navigation
### I want to...
#### ...understand what this app does
→ Start with [README.md](../README.md)
#### ...set up my development environment
→ Follow [SETUP.md](SETUP.md)
#### ...understand the code structure
→ Read [ARCHITECTURE.md](ARCHITECTURE.md)
#### ...work with the Mastodon API
→ Check [API.md](API.md)
#### ...contribute code
→ Read [CONTRIBUTING.md](../CONTRIBUTING.md)
#### ...build the APK
→ See "Building the Project" in [SETUP.md](SETUP.md)
#### ...fix a bug or add a feature
→ Follow the workflow in [CONTRIBUTING.md](../CONTRIBUTING.md)
## 📖 Reading Order
### For New Developers
1. **README.md** - Get an overview
2. **SETUP.md** - Set up your environment
3. **ARCHITECTURE.md** - Understand the codebase
4. **API.md** - Learn about the API
5. **CONTRIBUTING.md** - Start contributing
### For Contributors
1. **CONTRIBUTING.md** - Understand the process
2. **ARCHITECTURE.md** - Learn the architecture
3. **API.md** - API reference as needed
4. **SETUP.md** - Troubleshooting reference
### For Users
1. **README.md** - Features and installation
2. **LICENSE** - Usage terms
## 📝 Additional Resources
### Code Documentation
All public APIs are documented with KDoc comments:
```kotlin
/**
* Loads the public timeline from the Mastodon instance
*
* @param limit Maximum number of statuses to fetch
* @param local Show only local statuses if true
* @return Result containing list of statuses or error
*/
suspend fun getPublicTimeline(limit: Int, local: Boolean): Result<List<Status>>
```
### External Resources
- **[Android Developers](https://developer.android.com/)** - Official Android documentation
- **[Kotlin Docs](https://kotlinlang.org/docs/home.html)** - Kotlin programming language
- **[Jetpack Compose](https://developer.android.com/jetpack/compose)** - Modern UI toolkit
- **[Mastodon API](https://docs.joinmastodon.org/api/)** - API specification
- **[Material Design 3](https://m3.material.io/)** - Design system
## 🔍 Document Summaries
### README.md
- **Purpose**: Project introduction and quick start
- **Audience**: Everyone
- **Length**: Comprehensive overview
- **Key Sections**: Features, tech stack, building, configuration
### SETUP.md
- **Purpose**: Development environment setup
- **Audience**: Developers setting up for the first time
- **Length**: Detailed step-by-step guide
- **Key Sections**: Prerequisites, setup steps, troubleshooting
### ARCHITECTURE.md
- **Purpose**: Explain code organization and patterns
- **Audience**: Developers who want to understand or modify the codebase
- **Length**: In-depth technical documentation
- **Key Sections**: MVVM layers, data flow, state management
### API.md
- **Purpose**: Document Mastodon API integration
- **Audience**: Developers working with API calls
- **Length**: Comprehensive API reference
- **Key Sections**: Endpoints, data models, error handling
### CONTRIBUTING.md
- **Purpose**: Guide for project contributors
- **Audience**: Anyone who wants to contribute
- **Length**: Complete contribution guide
- **Key Sections**: Coding standards, commit guidelines, PR process
## 📋 Checklist for New Developers
Before starting development, make sure you've:
- [ ] Read the README.md
- [ ] Followed SETUP.md to set up your environment
- [ ] Successfully built and run the app
- [ ] Reviewed ARCHITECTURE.md to understand the code structure
- [ ] Read CONTRIBUTING.md to understand the workflow
- [ ] Familiarized yourself with the API.md for API details
## 🤝 Getting Help
If you can't find what you're looking for:
1. **Search the docs** - Use Ctrl+F to search within documents
2. **Check existing issues** - Someone may have asked already
3. **Read the code** - Code comments provide additional context
4. **Ask in discussions** - Start a conversation
5. **Open an issue** - For bugs or documentation gaps
## 📊 Documentation Status
| Document | Status | Last Updated |
|----------|--------|--------------|
| README.md | ✅ Complete | 2026-01-24 |
| SETUP.md | ✅ Complete | 2026-01-24 |
| ARCHITECTURE.md | ✅ Complete | 2026-01-24 |
| API.md | ✅ Complete | 2026-01-24 |
| CONTRIBUTING.md | ✅ Complete | 2026-01-24 |
| LICENSE | ✅ Complete | 2026-01-24 |
## 🔄 Keeping Documentation Updated
Documentation should be updated when:
- New features are added
- Architecture changes
- API integration changes
- Build process changes
- New dependencies are added
Contributors: Please update relevant docs with your changes!
---
**Note**: All documentation is written in English to ensure accessibility for the global developer community.
**Tip**: You can use tools like [Grip](https://github.com/joeyespo/grip) to preview Markdown files locally with GitHub styling.

364
docs/OAUTH_LOGIN.md Archivo normal
Ver fichero

@@ -0,0 +1,364 @@
# OAuth Login and Authentication
This document explains the OAuth authentication implementation in My ActivityPub app.
## Overview
The app supports two modes:
1. **Guest Mode** (Default): Browse public federated timeline without authentication
2. **Authenticated Mode**: Login with OAuth to access your home timeline and instance
## Features
### Guest Mode
- Opens directly to the public federated timeline from `mastodon.social`
- No authentication required
- Browse posts from the entire fediverse
- Option to login at any time
### Authenticated Mode
When logged in, users can:
- Access their home timeline (posts from followed accounts)
- Switch between Public and Home timelines
- See their username in the app header
- Logout when desired
- Persistent login (saved across app restarts)
## OAuth Flow
### 1. App Registration
When a user initiates login:
1. User enters their instance domain (e.g., `mastodon.online`)
2. App registers itself with the instance via `/api/v1/apps`
3. Receives `client_id` and `client_secret`
4. Credentials are saved securely in DataStore
### 2. Authorization
1. App constructs authorization URL:
```
https://{instance}/oauth/authorize?
client_id={client_id}&
scope=read write follow&
redirect_uri=myactivitypub://oauth&
response_type=code
```
2. Opens URL in Custom Chrome Tab (in-app browser)
3. User authorizes the app on their instance
4. Instance redirects to: `myactivitypub://oauth?code={auth_code}`
5. App catches the deep link and extracts the code
### 3. Token Exchange
1. App exchanges authorization code for access token
2. POST to `/oauth/token` with:
- `client_id`
- `client_secret`
- `code` (from authorization)
- `grant_type=authorization_code`
3. Receives `access_token`
4. Token is saved securely in DataStore
### 4. Authenticated Requests
All subsequent API requests include:
```
Authorization: Bearer {access_token}
```
## Architecture
### Components
#### AuthRepository
**Location**: `data/repository/AuthRepository.kt`
Handles all authentication operations:
- `registerApp()`: Register OAuth app with instance
- `getAuthorizationUrl()`: Generate OAuth URL
- `obtainToken()`: Exchange code for token
- `verifyCredentials()`: Verify user account
- `logout()`: Clear stored credentials
- `userSession`: Flow of current session state
#### AuthViewModel
**Location**: `ui/viewmodel/AuthViewModel.kt`
Manages authentication state:
```kotlin
sealed class AuthState {
object Loading
object NotAuthenticated
object LoggingIn
data class NeedsAuthorization(val authUrl: String)
object LoginComplete
data class Authenticated(val session: UserSession)
data class Error(val message: String)
}
```
#### LoginScreen
**Location**: `ui/screens/LoginScreen.kt`
UI for login:
- Instance domain input
- Login button
- Continue as guest option
- Error display
### Data Models
#### UserSession
```kotlin
data class UserSession(
val instance: String, // e.g., "mastodon.social"
val accessToken: String, // OAuth access token
val account: Account? // User account info
)
```
#### AppRegistration
```kotlin
data class AppRegistration(
val id: String,
val name: String,
val clientId: String,
val clientSecret: String,
val redirectUri: String
)
```
#### TokenResponse
```kotlin
data class TokenResponse(
val accessToken: String,
val tokenType: String, // Usually "Bearer"
val scope: String, // "read write follow"
val createdAt: Long
)
```
## Security
### Token Storage
- Tokens stored using **DataStore Preferences**
- Encrypted at rest by Android system
- Not accessible to other apps
- Cleared on logout
### OAuth Best Practices
✅ **Implemented**:
- Authorization Code Flow (most secure)
- Custom Chrome Tabs (prevents phishing)
- HTTPS only
- State parameter handling
- Token refresh (TODO)
❌ **Not Implemented** (Future):
- PKCE (Proof Key for Code Exchange)
- Token refresh
- Token expiration handling
- Revocation on logout
## API Endpoints
### Register App
```
POST /api/v1/apps
```
**Request**:
```
client_name=My ActivityPub
redirect_uris=myactivitypub://oauth
scopes=read write follow
```
**Response**:
```json
{
"id": "123",
"name": "My ActivityPub",
"client_id": "...",
"client_secret": "...",
"redirect_uri": "myactivitypub://oauth"
}
```
### Obtain Token
```
POST /oauth/token
```
**Request**:
```
client_id={client_id}
client_secret={client_secret}
redirect_uri=myactivitypub://oauth
grant_type=authorization_code
code={authorization_code}
scope=read write follow
```
**Response**:
```json
{
"access_token": "...",
"token_type": "Bearer",
"scope": "read write follow",
"created_at": 1234567890
}
```
### Verify Credentials
```
GET /api/v1/accounts/verify_credentials
Authorization: Bearer {access_token}
```
**Response**:
```json
{
"id": "123",
"username": "alice",
"display_name": "Alice",
"avatar": "https://...",
...
}
```
## Configuration
### OAuth Scopes
Currently requested scopes:
- `read`: Read user data
- `write`: Post statuses, follow accounts
- `follow`: Follow/unfollow accounts
### Redirect URI
Fixed redirect URI:
```
myactivitypub://oauth
```
Registered in `AndroidManifest.xml`:
```xml
<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>
```
## Usage Examples
### Login Flow (User Perspective)
1. **Open app**
- See public timeline (guest mode)
2. **Tap menu (⋮) → Login**
- Enter instance: `mastodon.social`
- Tap "Login" button
3. **Authorize in browser**
- Browser opens showing Mastodon login
- Enter credentials (if not logged in)
- Click "Authorize"
4. **Return to app**
- Automatically redirects back
- Shows "Loading..."
- Displays home timeline
5. **Use app**
- See home timeline (followed accounts)
- Tap menu → Switch to Public
- Tap menu → Logout (when done)
### Programmatic Usage
```kotlin
// In a Composable
val authViewModel: AuthViewModel = viewModel()
val userSession by authViewModel.userSession.collectAsState()
// Start login
authViewModel.startLogin("mastodon.social")
// Complete login (after OAuth callback)
authViewModel.completeLogin(authorizationCode)
// Logout
authViewModel.logout()
// Check if logged in
if (userSession != null) {
// User is authenticated
val username = userSession.account?.username
}
```
## Troubleshooting
### Common Issues
#### Login button doesn't work
**Symptoms**: Nothing happens when tapping Login
**Causes**:
- Invalid instance domain
- Network connectivity issues
- Instance doesn't support OAuth
**Solutions**:
1. Check internet connection
2. Verify instance domain (no `https://`, just domain)
3. Try a known instance like `mastodon.social`
#### Browser doesn't open
**Symptoms**: No browser appears after tapping Login
**Causes**:
- Chrome not installed
- Intent filter not configured
**Solutions**:
1. Install Chrome or another browser
2. Check `AndroidManifest.xml` has intent filter
#### Can't return to app after authorization
**Symptoms**: Stuck in browser after authorizing
**Causes**:
- Deep link not registered
- Activity launch mode incorrect
**Solutions**:
1. Verify `launchMode="singleTask"` in manifest
2. Check intent filter configuration
3. Manually return to app
#### Token errors
**Symptoms**: "Failed to obtain token" error
**Causes**:
- Invalid authorization code
- Expired code
- Network issues
**Solutions**:
1. Try logging in again
2. Check network connection
3. Clear app data and retry
### Debug Mode
To enable detailed logging:
```kotlin
val loggingInterceptor = HttpLoggingInterceptor().apply {
level = HttpLoggingInterceptor.Level.BODY // Change from BASIC
}
```
View logs:
```bash
adb logcat | grep -E "(OAuth|Auth|Token)"
```
## Future Enhancements
### Planned Features
- [ ] Token refresh mechanism
- [ ] PKCE support
- [ ] Multiple account support
- [ ] Account switching
- [ ] Remember last used instance
- [ ] Instance suggestions/autocomplete
- [ ] Token revocation on logout
- [ ] Offline mode with cached data
- [ ] Biometric authentication
- [ ] Session timeout
### Security Improvements
- [ ] Implement PKCE
- [ ] Add state parameter validation
- [ ] Token rotation
- [ ] Secure key storage (Android Keystore)
- [ ] Certificate pinning
## Testing
### Manual Testing
1. **Guest Mode**:
- Open app → Should show public timeline
- No login required
2. **Login Flow**:
- Tap menu → Login
- Enter `mastodon.social`
- Should open browser
- Authorize app
- Should return to app automatically
- Should show home timeline
3. **Timeline Switching**:
- Login first
- Tap menu → Switch to Public
- Should show public timeline
- Tap menu → Switch to Home
- Should show home timeline
4. **Logout**:
- While logged in
- Tap menu → Logout
- Should return to public timeline (guest mode)
### Automated Testing
```kotlin
@Test
fun authRepository_registerApp_success() = runTest {
val result = authRepository.registerApp("mastodon.social")
assertTrue(result.isSuccess)
}
@Test
fun authViewModel_login_updatesState() = runTest {
authViewModel.startLogin("mastodon.social")
// Verify state changes to LoggingIn, then NeedsAuthorization
}
```
## References
- [Mastodon OAuth Documentation](https://docs.joinmastodon.org/spec/oauth/)
- [RFC 6749 - OAuth 2.0](https://tools.ietf.org/html/rfc6749)
- [Android Custom Tabs](https://developer.chrome.com/docs/android/custom-tabs/)
- [DataStore Documentation](https://developer.android.com/topic/libraries/architecture/datastore)
## Support
For issues related to authentication:
1. Check this documentation
2. Review the code in `AuthRepository.kt`
3. Check LogCat for errors
4. Open an issue on GitHub with:
- Instance you're trying to connect to
- Error messages
- Steps to reproduce

248
docs/SETUP.md Archivo normal
Ver fichero

@@ -0,0 +1,248 @@
# Development Setup Guide
This guide will help you set up the My ActivityPub project for development.
## Prerequisites
### Required Software
1. **Android Studio**
- Version: Hedgehog (2023.1.1) or newer
- Download: https://developer.android.com/studio
2. **Java Development Kit (JDK)**
- Version: JDK 11 or higher
- Android Studio includes a JDK, or download from: https://adoptium.net/
3. **Git**
- Version: Latest stable
- Download: https://git-scm.com/downloads
4. **Android SDK**
- API Level 24 (Android 7.0) minimum
- API Level 35 (Android 14) target
- Installed via Android Studio SDK Manager
### Recommended Tools
- **Android Device** or **Emulator** for testing
- **ADB (Android Debug Bridge)** - included with Android Studio
- **Gradle** - wrapper included in project
## Project Setup
### 1. Clone the Repository
```bash
git clone https://github.com/your-username/MyActivityPub.git
cd MyActivityPub
```
### 2. Open in Android Studio
1. Launch Android Studio
2. Select **File > Open**
3. Navigate to the cloned repository
4. Click **OK**
### 3. Gradle Sync
Android Studio will automatically trigger a Gradle sync. If not:
1. Click **File > Sync Project with Gradle Files**
2. Wait for sync to complete
3. Resolve any errors if they appear
### 4. Install Dependencies
The Gradle build system will automatically download all dependencies:
- Kotlin standard library
- Jetpack Compose libraries
- Retrofit for networking
- Coil for image loading
- Material 3 components
### 5. Configure Build Variants
1. Click **Build > Select Build Variant**
2. Choose `debug` for development
3. Use `release` for production builds
## Building the Project
### Debug Build
```bash
# From command line
./gradlew assembleDebug
# Output location
app/build/outputs/apk/debug/app-debug.apk
```
### Release Build
```bash
# Create signed release APK
./gradlew assembleRelease
# Output location
app/build/outputs/apk/release/app-release.apk
```
## Running the App
### On Physical Device
1. Enable **Developer Options** on your Android device:
- Go to **Settings > About Phone**
- Tap **Build Number** 7 times
2. Enable **USB Debugging**:
- Go to **Settings > Developer Options**
- Enable **USB Debugging**
3. Connect device via USB
4. In Android Studio:
- Click **Run > Run 'app'**
- Or press **Shift + F10**
- Select your device
### On Emulator
1. Create an AVD (Android Virtual Device):
- Click **Tools > Device Manager**
- Click **Create Device**
- Select a device definition (e.g., Pixel 6)
- Select system image (API 24+)
- Click **Finish**
2. Run the app:
- Click **Run > Run 'app'**
- Select the emulator
## Configuration
### Gradle Properties
Edit `gradle.properties` to configure build settings:
```properties
# Increase memory for large projects
org.gradle.jvmargs=-Xmx4096m -Dfile.encoding=UTF-8
# Enable parallel builds
org.gradle.parallel=true
# Enable configuration cache (Gradle 8.0+)
org.gradle.configuration-cache=true
```
### API Configuration
To connect to a different Mastodon instance, edit `MainActivity.kt`:
```kotlin
val retrofit = Retrofit.Builder()
.baseUrl("https://your-instance.social/") // Change this
.client(client)
.addConverterFactory(GsonConverterFactory.create())
.build()
```
## Troubleshooting
### Common Issues
#### 1. Gradle Sync Failed
**Problem**: "Could not download dependencies"
**Solutions**:
```bash
# Clear Gradle cache
rm -rf ~/.gradle/caches/
./gradlew clean --no-daemon
# Or in Android Studio:
# File > Invalidate Caches > Invalidate and Restart
```
#### 2. Build Failed with Memory Error
**Problem**: "Java heap space" or "OutOfMemoryError"
**Solution**: Increase memory in `gradle.properties`:
```properties
org.gradle.jvmargs=-Xmx4096m
```
#### 3. SDK Not Found
**Problem**: "Failed to find target with hash string 'android-35'"
**Solution**:
1. Open SDK Manager: **Tools > SDK Manager**
2. Install Android 14.0 (API 35)
3. Sync Gradle files
#### 4. Emulator Won't Start
**Problem**: Emulator crashes or doesn't start
**Solutions**:
- Enable virtualization in BIOS/UEFI
- Install HAXM (Intel) or WHPX (Windows)
- Reduce emulator RAM allocation
- Use ARM system image instead of x86
#### 5. App Crashes on Launch
**Problem**: App crashes immediately after launch
**Solutions**:
- Check Logcat for error messages
- Verify internet permission in manifest
- Clear app data: `adb shell pm clear com.manalejandro.myactivitypub`
### Debugging
#### View Logs
```bash
# View all logs
adb logcat
# Filter by app
adb logcat | grep MyActivityPub
# Filter by tag
adb logcat -s TimelineViewModel
# Clear logs
adb logcat -c
```
#### Debug in Android Studio
1. Set breakpoints in code
2. Click **Run > Debug 'app'**
3. Interact with app
4. Use debug panel to inspect variables
## IDE Configuration
### Android Studio Settings
Recommended settings for development:
1. **Code Style**:
- **Settings > Editor > Code Style > Kotlin**
- Set from: **Kotlin style guide**
2. **Auto Import**:
- **Settings > Editor > General > Auto Import**
- Enable **Add unambiguous imports on the fly**
- Enable **Optimize imports on the fly**
3. **Live Templates**:
- **Settings > Editor > Live Templates**
- Useful templates: `comp`, `vm`, `repo`
4. **Version Control**:
- **Settings > Version Control > Git**
- Configure your Git author info
### Useful Plugins
- **Rainbow Brackets**: Colorize bracket pairs
- **GitToolBox**: Enhanced Git integration
- **Key Promoter X**: Learn keyboard shortcuts
- **ADB Idea**: Quick ADB commands
- **.ignore**: Manage .gitignore files
## Testing Setup
### Unit Tests
```bash
# Run all unit tests
./gradlew test
# Run tests for specific variant
./gradlew testDebugUnitTest
```
### Instrumented Tests
```bash
# Run on connected device/emulator
./gradlew connectedAndroidTest
```
### Test Coverage
```bash
# Generate coverage report
./gradlew jacocoTestReport
# View report at:
# app/build/reports/jacoco/test/html/index.html
```
## Code Quality
### Lint Checks
```bash
# Run lint
./gradlew lint
# View report at:
# app/build/reports/lint-results.html
```
### Static Analysis
```bash
# Run detekt (if configured)
./gradlew detekt
```
## Environment Variables
For sensitive data, use local environment variables:
```bash
# In ~/.bashrc or ~/.zshrc
export MASTODON_BASE_URL="https://mastodon.social/"
export API_KEY="your-api-key"
```
Access in Gradle:
```kotlin
android {
defaultConfig {
buildConfigField("String", "BASE_URL", "\"${System.getenv("MASTODON_BASE_URL")}\"")
}
}
```
## Next Steps
After setup:
1. Read [ARCHITECTURE.md](ARCHITECTURE.md) to understand the codebase
2. Check [CONTRIBUTING.md](../CONTRIBUTING.md) for contribution guidelines
3. Review [API.md](API.md) for API documentation
4. Start coding! 🚀
## Getting Help
If you encounter issues:
1. Check this guide thoroughly
2. Search existing [Issues](https://github.com/your-repo/issues)
3. Check [Stack Overflow](https://stackoverflow.com/questions/tagged/android)
4. Ask in [Discussions](https://github.com/your-repo/discussions)
5. Create a new issue with details
## Additional Resources
- [Android Developer Guides](https://developer.android.com/guide)
- [Kotlin Documentation](https://kotlinlang.org/docs/home.html)
- [Jetpack Compose Pathway](https://developer.android.com/courses/pathways/compose)
- [Mastodon API Docs](https://docs.joinmastodon.org/api/)

23
gradle.properties Archivo normal
Ver fichero

@@ -0,0 +1,23 @@
# Project-wide Gradle settings.
# IDE (e.g. Android Studio) users:
# Gradle settings configured through the IDE *will override*
# any settings specified in this file.
# For more details on how to configure your build environment visit
# http://www.gradle.org/docs/current/userguide/build_environment.html
# Specifies the JVM arguments used for the daemon process.
# The setting is particularly useful for tweaking memory settings.
org.gradle.jvmargs=-Xmx4096m -Dfile.encoding=UTF-8
# When configured, Gradle will run in incubating parallel mode.
# This option should only be used with decoupled projects. For more details, visit
# https://developer.android.com/r/tools/gradle-multi-project-decoupled-projects
# org.gradle.parallel=true
# AndroidX package structure to make it clearer which packages are bundled with the
# Android operating system, and which are packaged with your app's APK
# https://developer.android.com/topic/libraries/support-library/androidx-rn
android.useAndroidX=true
# Kotlin code style for this project: "official" or "obsolete":
kotlin.code.style=official
# Enables namespacing of each library's R class so that its R class includes only the
# resources declared in the library itself and none from the library's dependencies,
# thereby reducing the size of the R class for that library
android.nonTransitiveRClass=true

31
gradle/libs.versions.toml Archivo normal
Ver fichero

@@ -0,0 +1,31 @@
[versions]
agp = "9.0.0"
coreKtx = "1.10.1"
junit = "4.13.2"
junitVersion = "1.1.5"
espressoCore = "3.5.1"
lifecycleRuntimeKtx = "2.6.1"
activityCompose = "1.8.0"
kotlin = "2.0.21"
composeBom = "2024.09.00"
[libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
junit = { group = "junit", name = "junit", version.ref = "junit" }
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" }
androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }
androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" }
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
androidx-compose-ui = { group = "androidx.compose.ui", name = "ui" }
androidx-compose-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" }
androidx-compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
androidx-compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
androidx-compose-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" }
androidx-compose-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }

BIN
gradle/wrapper/gradle-wrapper.jar vendido Archivo normal

Archivo binario no mostrado.

9
gradle/wrapper/gradle-wrapper.properties vendido Archivo normal
Ver fichero

@@ -0,0 +1,9 @@
#Sat Jan 24 15:44:08 CET 2026
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionSha256Sum=a17ddd85a26b6a7f5ddb71ff8b05fc5104c0202c6e64782429790c933686c806
distributionUrl=https\://services.gradle.org/distributions/gradle-9.1.0-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

251
gradlew vendido Archivo ejecutable
Ver fichero

@@ -0,0 +1,251 @@
#!/bin/sh
#
# Copyright © 2015 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
CLASSPATH="\\\"\\\""
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"

94
gradlew.bat vendido Archivo normal
Ver fichero

@@ -0,0 +1,94 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:execute
@rem Setup the command line
set CLASSPATH=
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega

58
logo_myap_standard.svg Archivo normal
Ver fichero

@@ -0,0 +1,58 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="512" height="512" viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg">
<defs>
<!-- Gradient for modern look -->
<linearGradient id="blueGradient" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#3AA4E8;stop-opacity:1" />
<stop offset="100%" style="stop-color:#2B90D9;stop-opacity:1" />
</linearGradient>
<!-- Shadow filter -->
<filter id="shadow" x="-50%" y="-50%" width="200%" height="200%">
<feGaussianBlur in="SourceAlpha" stdDeviation="4"/>
<feOffset dx="0" dy="4" result="offsetblur"/>
<feComponentTransfer>
<feFuncA type="linear" slope="0.3"/>
</feComponentTransfer>
<feMerge>
<feMergeNode/>
<feMergeNode in="SourceGraphic"/>
</feMerge>
</filter>
</defs>
<!-- Background circle with gradient -->
<circle cx="256" cy="256" r="240" fill="url(#blueGradient)"/>
<!-- Subtle border -->
<circle cx="256" cy="256" r="240" fill="none" stroke="#1A6DA8" stroke-width="4" opacity="0.5"/>
<!-- Fediverse star symbol on top -->
<g transform="translate(256, 120)" filter="url(#shadow)">
<path d="M 0,-20 L 6,-6 L 20,-6 L 9,3 L 13,17 L 0,8 L -13,17 L -9,3 L -20,-6 L -6,-6 Z"
fill="#FFFFFF"
opacity="0.95"/>
</g>
<!-- Main text MyAP -->
<g filter="url(#shadow)">
<!-- M -->
<path d="M 100,220 L 100,340 L 120,340 L 120,260 L 155,330 L 170,330 L 205,260 L 205,340 L 225,340 L 225,220 L 195,220 L 162.5,300 L 130,220 Z"
fill="#FFFFFF"/>
<!-- y -->
<path d="M 240,240 L 240,310 L 225,345 L 245,345 L 262.5,315 L 280,345 L 300,345 L 275,305 L 275,240 Z"
fill="#FFFFFF"/>
<!-- A -->
<path d="M 305,220 L 280,340 L 300,340 L 307,305 L 343,305 L 350,340 L 370,340 L 345,220 Z M 315,285 L 325,245 L 335,285 Z"
fill="#FFFFFF"/>
<!-- P -->
<path d="M 385,220 L 385,340 L 405,340 L 405,295 L 445,295 Q 460,295 460,280 L 460,235 Q 460,220 445,220 Z M 405,240 L 440,240 L 440,275 L 405,275 Z"
fill="#FFFFFF"/>
</g>
<!-- Subtle shine effect on top -->
<ellipse cx="256" cy="180" rx="100" ry="60" fill="#FFFFFF" opacity="0.1"/>
</svg>

Después

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

23
settings.gradle.kts Archivo normal
Ver fichero

@@ -0,0 +1,23 @@
pluginManagement {
repositories {
google {
content {
includeGroupByRegex("com\\.android.*")
includeGroupByRegex("com\\.google.*")
includeGroupByRegex("androidx.*")
}
}
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = "My ActivityPub"
include(":app")