15
.gitignore
vendido
Archivo normal
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -0,0 +1 @@
|
||||
/build
|
||||
82
app/build.gradle.kts
Archivo normal
@@ -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
@@ -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
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
41
app/src/main/AndroidManifest.xml
Archivo normal
@@ -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>
|
||||
BIN
app/src/main/ic_launcher-playstore.png
Archivo normal
|
Después Anchura: | Altura: | Tamaño: 25 KiB |
605
app/src/main/java/com/manalejandro/myactivitypub/MainActivity.kt
Archivo normal
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
@@ -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>
|
||||
}
|
||||
22
app/src/main/java/com/manalejandro/myactivitypub/data/models/Account.kt
Archivo normal
@@ -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
|
||||
)
|
||||
33
app/src/main/java/com/manalejandro/myactivitypub/data/models/Auth.kt
Archivo normal
@@ -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
|
||||
)
|
||||
18
app/src/main/java/com/manalejandro/myactivitypub/data/models/Instance.kt
Archivo normal
@@ -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
|
||||
)
|
||||
@@ -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
|
||||
)
|
||||
@@ -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
|
||||
)
|
||||
23
app/src/main/java/com/manalejandro/myactivitypub/data/models/Status.kt
Archivo normal
@@ -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"
|
||||
)
|
||||
@@ -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
|
||||
)
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
226
app/src/main/java/com/manalejandro/myactivitypub/ui/components/StatusCard.kt
Archivo normal
@@ -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()
|
||||
}
|
||||
}
|
||||
160
app/src/main/java/com/manalejandro/myactivitypub/ui/screens/LoginScreen.kt
Archivo normal
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
""
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
27
app/src/main/java/com/manalejandro/myactivitypub/ui/theme/Color.kt
Archivo normal
@@ -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)
|
||||
|
||||
58
app/src/main/java/com/manalejandro/myactivitypub/ui/theme/Theme.kt
Archivo normal
@@ -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
|
||||
)
|
||||
}
|
||||
34
app/src/main/java/com/manalejandro/myactivitypub/ui/theme/Type.kt
Archivo normal
@@ -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
|
||||
)
|
||||
*/
|
||||
)
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
74
app/src/main/res/drawable/ic_launcher_background.xml
Archivo normal
@@ -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>
|
||||
37
app/src/main/res/drawable/ic_launcher_foreground.xml
Archivo normal
@@ -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>
|
||||
37
app/src/main/res/drawable/ic_launcher_foreground_new.xml
Archivo normal
@@ -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>
|
||||
43
app/src/main/res/drawable/ic_logo_myap.xml
Archivo normal
@@ -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>
|
||||
28
app/src/main/res/drawable/logo_myap.xml
Archivo normal
@@ -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 |
5
app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
Archivo normal
@@ -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>
|
||||
5
app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
Archivo normal
@@ -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>
|
||||
BIN
app/src/main/res/mipmap-hdpi/ic_launcher.webp
Archivo normal
|
Después Anchura: | Altura: | Tamaño: 1.9 KiB |
BIN
app/src/main/res/mipmap-hdpi/ic_launcher_round.webp
Archivo normal
|
Después Anchura: | Altura: | Tamaño: 3.4 KiB |
BIN
app/src/main/res/mipmap-mdpi/ic_launcher.webp
Archivo normal
|
Después Anchura: | Altura: | Tamaño: 1.6 KiB |
BIN
app/src/main/res/mipmap-mdpi/ic_launcher_round.webp
Archivo normal
|
Después Anchura: | Altura: | Tamaño: 2.4 KiB |
BIN
app/src/main/res/mipmap-xhdpi/ic_launcher.webp
Archivo normal
|
Después Anchura: | Altura: | Tamaño: 2.7 KiB |
BIN
app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp
Archivo normal
|
Después Anchura: | Altura: | Tamaño: 4.8 KiB |
BIN
app/src/main/res/mipmap-xxhdpi/ic_launcher.webp
Archivo normal
|
Después Anchura: | Altura: | Tamaño: 4.0 KiB |
BIN
app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp
Archivo normal
|
Después Anchura: | Altura: | Tamaño: 7.7 KiB |
BIN
app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp
Archivo normal
|
Después Anchura: | Altura: | Tamaño: 5.3 KiB |
BIN
app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp
Archivo normal
|
Después Anchura: | Altura: | Tamaño: 10 KiB |
10
app/src/main/res/values/colors.xml
Archivo normal
@@ -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>
|
||||
3
app/src/main/res/values/strings.xml
Archivo normal
@@ -0,0 +1,3 @@
|
||||
<resources>
|
||||
<string name="app_name">My ActivityPub</string>
|
||||
</resources>
|
||||
5
app/src/main/res/values/themes.xml
Archivo normal
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
|
||||
<style name="Theme.MyActivityPub" parent="android:Theme.Material.Light.NoActionBar" />
|
||||
</resources>
|
||||
13
app/src/main/res/xml/backup_rules.xml
Archivo normal
@@ -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>
|
||||
19
app/src/main/res/xml/data_extraction_rules.xml
Archivo normal
@@ -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>
|
||||
17
app/src/test/java/com/manalejandro/myactivitypub/ExampleUnitTest.kt
Archivo normal
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
9
gradle/wrapper/gradle-wrapper.properties
vendido
Archivo normal
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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")
|
||||