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