├── app ├── .gitignore ├── src │ ├── main │ │ ├── res │ │ │ ├── values-cs │ │ │ │ └── strings.xml │ │ │ ├── values-pt │ │ │ │ └── strings.xml │ │ │ ├── mipmap-hdpi │ │ │ │ ├── ic_launcher.webp │ │ │ │ └── ic_launcher_round.webp │ │ │ ├── mipmap-mdpi │ │ │ │ ├── ic_launcher.webp │ │ │ │ └── ic_launcher_round.webp │ │ │ ├── mipmap-xhdpi │ │ │ │ ├── ic_launcher.webp │ │ │ │ └── ic_launcher_round.webp │ │ │ ├── mipmap-xxhdpi │ │ │ │ ├── ic_launcher.webp │ │ │ │ └── ic_launcher_round.webp │ │ │ ├── mipmap-xxxhdpi │ │ │ │ ├── ic_launcher.webp │ │ │ │ └── ic_launcher_round.webp │ │ │ ├── xml │ │ │ │ ├── file_provider_paths.xml │ │ │ │ ├── backup_rules.xml │ │ │ │ ├── data_extraction_rules.xml │ │ │ │ └── locales_config.xml │ │ │ ├── values-night │ │ │ │ └── colors.xml │ │ │ ├── values │ │ │ │ ├── ic_launcher_background.xml │ │ │ │ ├── betterlinkmovementmethod.xml │ │ │ │ └── colors.xml │ │ │ ├── values-v31 │ │ │ │ └── colors.xml │ │ │ ├── values-night-v31 │ │ │ │ └── colors.xml │ │ │ ├── drawable │ │ │ │ ├── triangle.xml │ │ │ │ ├── down_filled.xml │ │ │ │ ├── up_filled.xml │ │ │ │ ├── up_outline.xml │ │ │ │ ├── down_outline.xml │ │ │ │ ├── emergency_home_fill0_wght400_grad0_opsz48.xml │ │ │ │ ├── matrix_favicon.xml │ │ │ │ ├── error_placeholder.xml │ │ │ │ └── ic_launcher_mono.xml │ │ │ ├── mipmap-anydpi-v33 │ │ │ │ ├── ic_launcher.xml │ │ │ │ └── ic_launcher_round.xml │ │ │ ├── mipmap-anydpi │ │ │ │ ├── ic_launcher.xml │ │ │ │ └── ic_launcher_round.xml │ │ │ └── values-zh-rTW │ │ │ │ └── strings.xml │ │ ├── java │ │ │ └── com │ │ │ │ └── jerboa │ │ │ │ ├── ui │ │ │ │ ├── components │ │ │ │ │ ├── videoviewer │ │ │ │ │ │ ├── ResourceMissing.kt │ │ │ │ │ │ ├── EmbeddedData.kt │ │ │ │ │ │ ├── hosts │ │ │ │ │ │ │ ├── SupportedVideoHost.kt │ │ │ │ │ │ │ ├── DirectFileVideoHost.kt │ │ │ │ │ │ │ ├── jerboa-video-hosts-research.md │ │ │ │ │ │ │ └── SendvidVideoHost.kt │ │ │ │ │ │ ├── VideoHostComposer.kt │ │ │ │ │ │ └── OpenGraphParser.kt │ │ │ │ │ ├── common │ │ │ │ │ │ ├── Text.kt │ │ │ │ │ │ ├── JerboaSnackBar.kt │ │ │ │ │ │ ├── StateTriggers.kt │ │ │ │ │ │ ├── Buttons.kt │ │ │ │ │ │ ├── AccountHelpers.kt │ │ │ │ │ │ ├── Image.kt │ │ │ │ │ │ ├── ApiStateHelpers.kt │ │ │ │ │ │ ├── SwipeToNavigateBack.kt │ │ │ │ │ │ ├── Modifiers.kt │ │ │ │ │ │ └── PopupItems.kt │ │ │ │ │ ├── community │ │ │ │ │ │ └── sidebar │ │ │ │ │ │ │ ├── CommunitySidebar.kt │ │ │ │ │ │ │ └── CommunitySidebarScreen.kt │ │ │ │ │ ├── home │ │ │ │ │ │ ├── sidebar │ │ │ │ │ │ │ └── SiteSidebar.kt │ │ │ │ │ │ └── legal │ │ │ │ │ │ │ └── SiteLegalScreen.kt │ │ │ │ │ ├── remove │ │ │ │ │ │ └── RemoveItem.kt │ │ │ │ │ ├── report │ │ │ │ │ │ └── CreateReport.kt │ │ │ │ │ ├── comment │ │ │ │ │ │ └── edit │ │ │ │ │ │ │ └── CommentEdit.kt │ │ │ │ │ ├── settings │ │ │ │ │ │ └── account │ │ │ │ │ │ │ └── AccountSettingsScreen.kt │ │ │ │ │ ├── ban │ │ │ │ │ │ └── BanPerson.kt │ │ │ │ │ ├── login │ │ │ │ │ │ └── LoginScreen.kt │ │ │ │ │ └── privatemessage │ │ │ │ │ │ └── PrivateMessageReply.kt │ │ │ │ └── theme │ │ │ │ │ ├── Shape.kt │ │ │ │ │ ├── JerboaColorScheme.kt │ │ │ │ │ └── Sizes.kt │ │ │ │ ├── api │ │ │ │ ├── ApiAction.kt │ │ │ │ └── ApiState.kt │ │ │ │ ├── feat │ │ │ │ ├── PostActionBarMode.kt │ │ │ │ ├── PostNavigationGestureMode.kt │ │ │ │ ├── ImageProxySupport.kt │ │ │ │ ├── BlurNSFW.kt │ │ │ │ ├── ModActions.kt │ │ │ │ └── Voting.kt │ │ │ │ ├── feed │ │ │ │ ├── PaginationController.kt │ │ │ │ ├── UniqueFeedController.kt │ │ │ │ ├── ApiActionController.kt │ │ │ │ ├── PostController.kt │ │ │ │ └── FeedController.kt │ │ │ │ ├── db │ │ │ │ ├── AppDBContainer.kt │ │ │ │ ├── dao │ │ │ │ │ ├── AppSettingsDao.kt │ │ │ │ │ └── AccountDao.kt │ │ │ │ ├── repository │ │ │ │ │ ├── AppSettingsRepository.kt │ │ │ │ │ └── AccountRepository.kt │ │ │ │ └── entity │ │ │ │ │ └── Account.kt │ │ │ │ ├── util │ │ │ │ ├── downloadprogress │ │ │ │ │ ├── DownloadProgress.kt │ │ │ │ │ ├── ProgressEvent.kt │ │ │ │ │ ├── DownloadProgressInterceptor.kt │ │ │ │ │ └── DownloadProgressResponseBody.kt │ │ │ │ └── markwon │ │ │ │ │ ├── ForceHttpsPlugin.kt │ │ │ │ │ ├── ScriptRewriteSupportPlugin.kt │ │ │ │ │ └── MarkwonLemmyLinkPlugin.kt │ │ │ │ ├── Constants.kt │ │ │ │ ├── model │ │ │ │ ├── HomeViewModel.kt │ │ │ │ ├── PostEditViewModel.kt │ │ │ │ ├── PrivateMessageReplyViewModel.kt │ │ │ │ ├── CommentEditViewModel.kt │ │ │ │ ├── CreatePostViewModel.kt │ │ │ │ ├── AppSettingsViewModel.kt │ │ │ │ ├── PostRemoveViewModel.kt │ │ │ │ ├── CommentRemoveViewModel.kt │ │ │ │ ├── AccountSettingsViewModel.kt │ │ │ │ ├── PostLikesViewModel.kt │ │ │ │ └── CommentLikesViewModel.kt │ │ │ │ ├── datatypes │ │ │ │ └── Utils.kt │ │ │ │ ├── JerboaApplication.kt │ │ │ │ ├── state │ │ │ │ └── VideoAppState.kt │ │ │ │ └── DefaultInstances.kt │ │ └── assets │ │ │ └── RELEASES.md │ ├── debug │ │ └── res │ │ │ └── values │ │ │ └── strings.xml │ ├── androidTest │ │ └── java │ │ │ └── com │ │ │ └── jerboa │ │ │ ├── ExampleInstrumentedTest.kt │ │ │ ├── MigrationsTest.kt │ │ │ ├── ui │ │ │ └── components │ │ │ │ └── videoviewer │ │ │ │ └── hosts │ │ │ │ └── DirectFileVideoHostTest.kt │ │ │ └── feat │ │ │ └── ImageProxySupportTest.kt │ └── test │ │ └── java │ │ ├── android │ │ └── util │ │ │ └── Log.java │ │ └── com │ │ └── jerboa │ │ ├── ui │ │ └── components │ │ │ ├── videoviewer │ │ │ └── api │ │ │ │ └── OpenGraphParserTest.kt │ │ │ └── common │ │ │ └── LemmyLinkPluginTest.kt │ │ ├── ExampleUnitTest.kt │ │ ├── util │ │ └── markwon │ │ │ └── ScriptRewriteSupportPluginTest.kt │ │ └── feed │ │ └── UniqueFeedControllerTest.kt ├── proguard-rules.pro └── schemas │ └── com.jerboa.db.AppDB │ ├── 1.json │ └── 2.json ├── .github ├── CODEOWNERS └── ISSUE_TEMPLATE │ ├── question---discussions.md │ └── feature_request.yaml ├── benchmarks ├── src │ └── main │ │ ├── AndroidManifest.xml │ │ └── java │ │ └── com │ │ └── jerboa │ │ ├── baselineprofile │ │ ├── StartupProfileGenerator.kt │ │ └── BaselineProfileGenerator.kt │ │ ├── benchmarks │ │ ├── ScrollPostsBenchmarks.kt │ │ ├── TypicalUserJourneyBenchmarks.kt │ │ ├── ScrollCommentsBenchmarks.kt │ │ └── StartupBenchmarks.kt │ │ └── Utils.kt └── build.gradle.kts ├── fastlane └── metadata │ └── android │ ├── en-US │ ├── title.txt │ ├── short_description.txt │ ├── images │ │ ├── icon.png │ │ └── phoneScreenshots │ │ │ ├── 01.png │ │ │ └── 02.png │ ├── changelogs │ │ ├── 83.txt │ │ ├── 80.txt │ │ ├── 84.txt │ │ ├── 78.txt │ │ └── 79.txt │ └── full_description.txt │ └── de │ └── short_description.txt ├── media └── example_cmd.png ├── .gitattributes ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── renovate.json ├── scripts ├── deploy_to_device.sh ├── build.sh └── generate_changelog.sh ├── .gitignore ├── settings.gradle.kts ├── .editorconfig ├── compose_compiler_config.conf ├── RESOURCES.md ├── .run ├── Generate Baseline Profile.run.xml └── Generate Baseline Profile (show display).run.xml ├── gradle.properties ├── .woodpecker.yml ├── cliff.toml └── gradlew.bat /app/.gitignore: -------------------------------------------------------------------------------- 1 | /build 2 | /release 3 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @dessalines @MV-GH 2 | -------------------------------------------------------------------------------- /benchmarks/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/title.txt: -------------------------------------------------------------------------------- 1 | Jerboa for Lemmy 2 | -------------------------------------------------------------------------------- /media/example_cmd.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LemmyNet/jerboa/main/media/example_cmd.png -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Normalize EOL for all files that Git considers text files. 2 | * text=auto eol=lf -------------------------------------------------------------------------------- /fastlane/metadata/android/de/short_description.txt: -------------------------------------------------------------------------------- 1 | Client für Lemmy, eine föderierte Reddit-Alternative -------------------------------------------------------------------------------- /app/src/main/res/values-cs/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /app/src/main/res/values-pt/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/short_description.txt: -------------------------------------------------------------------------------- 1 | An app for Lemmy, a federated reddit alternative. 2 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LemmyNet/jerboa/main/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /app/src/debug/res/values/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | Jerboa (Debug) 3 | 4 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-hdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LemmyNet/jerboa/main/app/src/main/res/mipmap-hdpi/ic_launcher.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-mdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LemmyNet/jerboa/main/app/src/main/res/mipmap-mdpi/ic_launcher.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xhdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LemmyNet/jerboa/main/app/src/main/res/mipmap-xhdpi/ic_launcher.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxhdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LemmyNet/jerboa/main/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LemmyNet/jerboa/main/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/images/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LemmyNet/jerboa/main/fastlane/metadata/android/en-US/images/icon.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-hdpi/ic_launcher_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LemmyNet/jerboa/main/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-mdpi/ic_launcher_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LemmyNet/jerboa/main/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LemmyNet/jerboa/main/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LemmyNet/jerboa/main/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LemmyNet/jerboa/main/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp -------------------------------------------------------------------------------- /app/src/main/res/xml/file_provider_paths.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /app/src/main/res/values-night/colors.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | #FF121212 4 | 5 | -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/images/phoneScreenshots/01.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LemmyNet/jerboa/main/fastlane/metadata/android/en-US/images/phoneScreenshots/01.png -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/images/phoneScreenshots/02.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LemmyNet/jerboa/main/fastlane/metadata/android/en-US/images/phoneScreenshots/02.png -------------------------------------------------------------------------------- /app/src/main/res/values/ic_launcher_background.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | #FFFFFF 4 | -------------------------------------------------------------------------------- /app/src/main/res/values-v31/colors.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | @android:color/system_neutral1_0 4 | 5 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": ["config:recommended"], 4 | "schedule": ["every weekend"], 5 | "automerge": true 6 | } 7 | -------------------------------------------------------------------------------- /scripts/deploy_to_device.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | set -e 3 | 4 | pushd ../ 5 | 6 | # Build and push 7 | ./gradlew installDebug 8 | 9 | # Run the app 10 | adb shell monkey -p com.jerboa 1 11 | -------------------------------------------------------------------------------- /app/src/main/java/com/jerboa/ui/components/videoviewer/ResourceMissing.kt: -------------------------------------------------------------------------------- 1 | package com.jerboa.ui.components.videoviewer 2 | 3 | class ResourceMissing : Exception("Resource is not available anymore") 4 | -------------------------------------------------------------------------------- /app/src/main/res/values-night-v31/colors.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | @android:color/system_neutral1_900 4 | 5 | -------------------------------------------------------------------------------- /app/src/main/res/values/betterlinkmovementmethod.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.iml 2 | .gradle 3 | /local.properties 4 | /.idea 5 | .DS_Store 6 | /build 7 | /*/build/ 8 | /captures 9 | /Gemfile* 10 | .externalNativeBuild 11 | .cxx 12 | local.properties 13 | .project 14 | .settings 15 | .classpath 16 | .kotlin 17 | -------------------------------------------------------------------------------- /scripts/build.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | pushd ../ 3 | 4 | ./gradlew clean 5 | ./gradlew assembleRelease 6 | ./gradlew bundleRelease 7 | 8 | cp app/build/outputs/apk/release/app-release.apk ~/Sync/ 9 | cp app/build/outputs/bundle/release/app-release.aab ~/Sync/ 10 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/question---discussions.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Question / Discussions 3 | about: A question / discussion 4 | title: "" 5 | labels: "" 6 | assignees: "" 7 | --- 8 | 9 | Don't use this for questions / discussions. Use the official community at: 10 | 11 | https://lemmy.ml/c/jerboa 12 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/triangle.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 10 | -------------------------------------------------------------------------------- /app/src/main/java/com/jerboa/ui/components/videoviewer/EmbeddedData.kt: -------------------------------------------------------------------------------- 1 | package com.jerboa.ui.components.videoviewer 2 | 3 | data class EmbeddedData( 4 | val videoUrl: String?, 5 | val thumbnailUrl: String?, 6 | val typeName: String?, 7 | val title: String?, 8 | val height: Int?, 9 | val width: Int?, 10 | val aspectRatio: Float?, 11 | ) 12 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-anydpi-v33/ic_launcher.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-anydpi/ic_launcher.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-anydpi-v33/ic_launcher_round.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-anydpi/ic_launcher_round.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | distributionBase=GRADLE_USER_HOME 2 | distributionPath=wrapper/dists 3 | distributionSha256Sum=72f44c9f8ebcb1af43838f45ee5c4aa9c5444898b3468ab3f4af7b6076c5bc3f 4 | distributionUrl=https\://services.gradle.org/distributions/gradle-9.2.1-bin.zip 5 | networkTimeout=10000 6 | validateDistributionUrl=true 7 | zipStoreBase=GRADLE_USER_HOME 8 | zipStorePath=wrapper/dists 9 | -------------------------------------------------------------------------------- /app/src/main/java/com/jerboa/ui/components/videoviewer/hosts/SupportedVideoHost.kt: -------------------------------------------------------------------------------- 1 | package com.jerboa.ui.components.videoviewer.hosts 2 | 3 | import com.jerboa.ui.components.videoviewer.EmbeddedData 4 | 5 | sealed interface SupportedVideoHost { 6 | fun isSupported(url: String): Boolean 7 | 8 | fun getVideoData(url: String): Result 9 | 10 | fun getShortTypeName(): String 11 | } 12 | -------------------------------------------------------------------------------- /app/src/main/java/com/jerboa/ui/theme/Shape.kt: -------------------------------------------------------------------------------- 1 | package com.jerboa.ui.theme 2 | 3 | import androidx.compose.foundation.shape.RoundedCornerShape 4 | import androidx.compose.material3.Shapes 5 | import androidx.compose.ui.unit.dp 6 | 7 | val Shapes = 8 | Shapes( 9 | small = RoundedCornerShape(0.dp), 10 | medium = RoundedCornerShape(6.dp), 11 | large = RoundedCornerShape(12.dp), 12 | ) 13 | -------------------------------------------------------------------------------- /app/src/main/java/com/jerboa/api/ApiAction.kt: -------------------------------------------------------------------------------- 1 | package com.jerboa.api 2 | 3 | sealed class ApiAction( 4 | val data: T, 5 | ) { 6 | class Ok( 7 | data: T, 8 | ) : ApiAction(data) 9 | 10 | class Loading( 11 | data: T, 12 | ) : ApiAction(data) 13 | 14 | class Failed( 15 | data: T, 16 | val err: Throwable, 17 | ) : ApiAction(data) 18 | } 19 | -------------------------------------------------------------------------------- /app/src/main/java/com/jerboa/feat/PostActionBarMode.kt: -------------------------------------------------------------------------------- 1 | package com.jerboa.feat 2 | 3 | import androidx.annotation.StringRes 4 | import com.jerboa.R 5 | 6 | enum class PostActionBarMode( 7 | @param:StringRes val resId: Int, 8 | ) { 9 | RightHandShort(R.string.post_actionbar_mode_short_right), 10 | LeftHandShort(R.string.post_actionbar_mode_short_left), 11 | Long(R.string.post_actionbar_mode_long), 12 | } 13 | -------------------------------------------------------------------------------- /settings.gradle.kts: -------------------------------------------------------------------------------- 1 | pluginManagement { 2 | repositories { 3 | google() 4 | mavenCentral() 5 | gradlePluginPortal() 6 | } 7 | } 8 | dependencyResolutionManagement { 9 | repositoriesMode = RepositoriesMode.FAIL_ON_PROJECT_REPOS 10 | repositories { 11 | google() 12 | mavenCentral() 13 | maven { url = uri("https://jitpack.io") } 14 | } 15 | } 16 | rootProject.name = "jerboa" 17 | include(":app") 18 | include(":benchmarks") 19 | -------------------------------------------------------------------------------- /app/src/main/java/com/jerboa/feed/PaginationController.kt: -------------------------------------------------------------------------------- 1 | package com.jerboa.feed 2 | 3 | import it.vercruysse.lemmyapi.datatypes.PaginationCursor 4 | 5 | class PaginationController( 6 | var page: Long = 1, 7 | var pageCursor: PaginationCursor? = null, 8 | ) { 9 | fun reset() { 10 | page = 1 11 | pageCursor = null 12 | } 13 | 14 | fun nextPage(pageCursor: PaginationCursor?) { 15 | page++ 16 | this.pageCursor = pageCursor 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/changelogs/83.txt: -------------------------------------------------------------------------------- 1 | ## What's Changed in 0.0.83 2 | 3 | - Add Video screen viewer, FeedVideoPlayer, plus support for popular non OGP videohosts. by @MV-GH in [#1922](https://github.com/LemmyNet/jerboa/pull/1922) 4 | - Fix #1884, rare case markdown actions can cause crashes by @MV-GH in [#1889](https://github.com/LemmyNet/jerboa/pull/1889) 5 | 6 | **Full Changelog**: https://github.com/LemmyNet/jerboa/compare/0.0.81-test11...0.0.83 7 | 8 | 9 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/down_filled.xml: -------------------------------------------------------------------------------- 1 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*.{kt,kts}] 4 | ktlint_standard_discouraged-comment-location = disabled 5 | ktlint_standard_if-else-wrapping = disabled 6 | ktlint_standard_no-wildcard-imports = disabled 7 | ktlint_standard_comment-wrapping = disabled 8 | ktlint_function_naming_ignore_when_annotated_with= Composable 9 | ktlint_standard_multiline-expression-wrapping = disabled 10 | ktlint_standard_string-template-indent = disabled 11 | max_line_length = 140 12 | 13 | [app/src/main/java/com/jerboa/DefaultInstances.kt] 14 | ktlint = disabled 15 | 16 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/up_filled.xml: -------------------------------------------------------------------------------- 1 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /app/src/main/java/com/jerboa/db/AppDBContainer.kt: -------------------------------------------------------------------------------- 1 | package com.jerboa.db 2 | 3 | import android.content.Context 4 | import com.jerboa.db.repository.AccountRepository 5 | import com.jerboa.db.repository.AppSettingsRepository 6 | 7 | class AppDBContainer( 8 | private val context: Context, 9 | ) { 10 | private val database by lazy { AppDB.getDatabase(context) } 11 | val accountRepository by lazy { AccountRepository(database.accountDao()) } 12 | val appSettingsRepository by lazy { AppSettingsRepository(database.appSettingsDao()) } 13 | } 14 | -------------------------------------------------------------------------------- /app/src/main/res/values/colors.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | #FFBB86FC 4 | #FF6200EE 5 | #FF3700B3 6 | #FF03DAC5 7 | #FF018786 8 | #FF000000 9 | #FFFFFFFF 10 | #FF0e1d29 11 | #FFFFFFFF 12 | 13 | -------------------------------------------------------------------------------- /app/src/main/res/xml/backup_rules.xml: -------------------------------------------------------------------------------- 1 | 8 | 9 | 13 | -------------------------------------------------------------------------------- /app/src/main/java/com/jerboa/util/downloadprogress/DownloadProgress.kt: -------------------------------------------------------------------------------- 1 | package com.jerboa.util.downloadprogress 2 | 3 | import com.jerboa.api.API 4 | import kotlinx.coroutines.flow.MutableStateFlow 5 | 6 | object DownloadProgress { 7 | private val initProgress = ProgressEvent("", 0, 0) 8 | 9 | val downloadProgressFlow = MutableStateFlow(initProgress) 10 | 11 | val downloadProgressHttpClient = 12 | API.httpClient 13 | .newBuilder() 14 | .addInterceptor(DownloadProgressInterceptor(downloadProgressFlow)) 15 | .build() 16 | } 17 | -------------------------------------------------------------------------------- /app/src/main/java/com/jerboa/feat/PostNavigationGestureMode.kt: -------------------------------------------------------------------------------- 1 | package com.jerboa.feat 2 | 3 | import androidx.annotation.StringRes 4 | import com.jerboa.R 5 | 6 | enum class PostNavigationGestureMode( 7 | @param:StringRes val resId: Int, 8 | ) { 9 | /** 10 | * Disable all navigation gestures within posts. 11 | */ 12 | Disabled(R.string.look_and_feel_post_navigation_gesture_mode_disabled), 13 | 14 | /** 15 | * Enable swiping right to navigate away from a post. 16 | */ 17 | SwipeRight(R.string.look_and_feel_post_navigation_gesture_mode_swipe_right), 18 | } 19 | -------------------------------------------------------------------------------- /app/src/main/java/com/jerboa/Constants.kt: -------------------------------------------------------------------------------- 1 | package com.jerboa 2 | 3 | import kotlinx.serialization.json.Json 4 | 5 | const val DEBOUNCE_DELAY = 1000L 6 | const val MAX_POST_TITLE_LENGTH = 200 7 | 8 | /** 9 | * Hides the downvote or percentage, if below this threshold 10 | */ 11 | const val SHOW_UPVOTE_PCT_THRESHOLD = 0.9F 12 | const val VIEW_VOTES_LIMIT = 40L 13 | 14 | val ALLOWED_SCHEMES = listOf("http", "https", "magnet") 15 | 16 | // URLs 17 | const val DONATE_LINK = "https://join-lemmy.org/donate" 18 | 19 | val JSON = Json { 20 | ignoreUnknownKeys = true 21 | coerceInputValues = true 22 | } 23 | -------------------------------------------------------------------------------- /compose_compiler_config.conf: -------------------------------------------------------------------------------- 1 | // This file marks classes as stable for the Compose compiler 2 | // see https://developer.android.com/jetpack/compose/performance/stability/fix#configuration-file 3 | 4 | // External modules need to be marked as stable, the compiler doesn't run on them 5 | // They are unknown and thus declared as unstable 6 | it.vercruysse.lemmyapi.*.datatypes.** 7 | arrow.core.Either 8 | 9 | // Consider List, etc stable, see https://github.com/LemmyNet/jerboa/issues/1332 10 | kotlin.collections.* 11 | 12 | // TODO check if still needed in PostOptionsDropdown 13 | kotlinx.coroutines.CoroutineScope 14 | -------------------------------------------------------------------------------- /app/src/main/java/com/jerboa/ui/components/common/Text.kt: -------------------------------------------------------------------------------- 1 | package com.jerboa.ui.components.common 2 | 3 | import androidx.compose.material3.MaterialTheme 4 | import androidx.compose.material3.Text 5 | import androidx.compose.runtime.Composable 6 | import androidx.compose.ui.tooling.preview.Preview 7 | 8 | @Preview( 9 | showBackground = true, 10 | widthDp = 360, 11 | ) 12 | @Composable 13 | private fun TitlePreview() { 14 | Title("This is my title") 15 | } 16 | 17 | @Composable 18 | fun Title(text: String) { 19 | Text( 20 | text = text, 21 | style = MaterialTheme.typography.titleMedium, 22 | ) 23 | } 24 | -------------------------------------------------------------------------------- /app/src/main/res/xml/data_extraction_rules.xml: -------------------------------------------------------------------------------- 1 | 6 | 7 | 8 | 12 | 13 | 19 | -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/full_description.txt: -------------------------------------------------------------------------------- 1 | Jerboa is an app for Lemmy, a federated reddit alternative. Jerboa is made by Lemmy's developers, and is free, open-source software, meaning no advertising, monetizing, or venture capital, ever. 2 | 3 | Lemmy is similar to sites like Reddit, Lobste.rs, or Hacker News: you subscribe to forums you're interested in, post links and discussions, then vote, and comment on them. Behind the scenes, it is very different; anyone can easily run a server, and all these servers are federated (think email), and connected to the same universe, called the Fediverse. 4 | -------------------------------------------------------------------------------- /app/src/main/java/com/jerboa/ui/theme/JerboaColorScheme.kt: -------------------------------------------------------------------------------- 1 | package com.jerboa.ui.theme 2 | 3 | import androidx.compose.material3.ColorScheme 4 | import androidx.compose.material3.darkColorScheme 5 | import androidx.compose.ui.graphics.Color 6 | 7 | /** 8 | * Provides custom Jerboa colors in addition to the default Material colors. 9 | */ 10 | data class JerboaColorScheme( 11 | // the default Material color scheme 12 | val material: ColorScheme = darkColorScheme(), 13 | // the color that highlights an image thumb 14 | val imageHighlight: Color = Color(0xCCD1D1D1), 15 | // the color that highlights a video thumb 16 | val videoHighlight: Color = Color(0xCCC20000), 17 | ) 18 | -------------------------------------------------------------------------------- /app/src/main/java/com/jerboa/util/downloadprogress/ProgressEvent.kt: -------------------------------------------------------------------------------- 1 | package com.jerboa.util.downloadprogress 2 | 3 | data class ProgressEvent( 4 | val progress: Float, 5 | val contentLength: Long, 6 | val downloadURL: String, 7 | val bytesRead: Long, 8 | val progressAvailable: Boolean, 9 | ) { 10 | constructor(downloadIdentifier: String, contentLength: Long, bytesRead: Long) : 11 | this( 12 | progress = (bytesRead.toFloat() / contentLength), 13 | contentLength = contentLength, 14 | downloadURL = downloadIdentifier, 15 | bytesRead = bytesRead, 16 | progressAvailable = contentLength > 0, 17 | ) 18 | } 19 | -------------------------------------------------------------------------------- /app/src/main/java/com/jerboa/api/ApiState.kt: -------------------------------------------------------------------------------- 1 | package com.jerboa.api 2 | 3 | sealed class ApiState { 4 | abstract class Holder( 5 | val data: T, 6 | ) : ApiState() 7 | 8 | class Success( 9 | data: T, 10 | ) : Holder(data) 11 | 12 | class Appending( 13 | data: T, 14 | ) : Holder(data) 15 | 16 | class AppendingFailure( 17 | data: T, 18 | ) : Holder(data) 19 | 20 | class Failure( 21 | val msg: Throwable, 22 | ) : ApiState() 23 | 24 | data object Loading : ApiState() 25 | 26 | data object Refreshing : ApiState() 27 | 28 | data object Empty : ApiState() 29 | } 30 | -------------------------------------------------------------------------------- /app/src/main/java/com/jerboa/model/HomeViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.jerboa.model 2 | 3 | import androidx.lifecycle.viewmodel.initializer 4 | import androidx.lifecycle.viewmodel.viewModelFactory 5 | import com.jerboa.db.repository.AccountRepository 6 | import com.jerboa.jerboaApplication 7 | 8 | class HomeViewModel( 9 | accountRepository: AccountRepository, 10 | ) : PostsViewModel(accountRepository) { 11 | init { 12 | init() 13 | } 14 | 15 | companion object { 16 | val Factory = 17 | viewModelFactory { 18 | initializer { 19 | HomeViewModel(jerboaApplication().container.accountRepository) 20 | } 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /app/src/main/java/com/jerboa/db/dao/AppSettingsDao.kt: -------------------------------------------------------------------------------- 1 | package com.jerboa.db.dao 2 | 3 | import androidx.lifecycle.LiveData 4 | import androidx.room.Dao 5 | import androidx.room.Query 6 | import androidx.room.Update 7 | import com.jerboa.db.entity.AppSettings 8 | 9 | @Dao 10 | interface AppSettingsDao { 11 | @Query("SELECT * FROM AppSettings limit 1") 12 | fun getSettings(): LiveData 13 | 14 | @Update 15 | suspend fun updateAppSettings(appSettings: AppSettings) 16 | 17 | @Query("UPDATE AppSettings SET last_version_code_viewed = :versionCode") 18 | suspend fun updateLastVersionCode(versionCode: Int) 19 | 20 | @Query("UPDATE AppSettings set post_view_mode = :postViewMode") 21 | suspend fun updatePostViewMode(postViewMode: Int) 22 | } 23 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/up_outline.xml: -------------------------------------------------------------------------------- 1 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /app/src/main/java/com/jerboa/ui/components/common/JerboaSnackBar.kt: -------------------------------------------------------------------------------- 1 | package com.jerboa.ui.components.common 2 | 3 | import androidx.compose.material3.MaterialTheme 4 | import androidx.compose.material3.Snackbar 5 | import androidx.compose.material3.SnackbarHost 6 | import androidx.compose.material3.SnackbarHostState 7 | import androidx.compose.runtime.Composable 8 | 9 | @Composable 10 | fun JerboaSnackbarHost(snackbarHostState: SnackbarHostState) { 11 | SnackbarHost(snackbarHostState) { 12 | Snackbar( 13 | snackbarData = it, 14 | containerColor = MaterialTheme.colorScheme.background, 15 | dismissActionContentColor = MaterialTheme.colorScheme.onBackground, 16 | contentColor = MaterialTheme.colorScheme.onBackground, 17 | ) 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /app/src/androidTest/java/com/jerboa/ExampleInstrumentedTest.kt: -------------------------------------------------------------------------------- 1 | package com.jerboa 2 | 3 | import androidx.test.ext.junit.runners.AndroidJUnit4 4 | import androidx.test.platform.app.InstrumentationRegistry 5 | import org.junit.Assert.assertTrue 6 | import org.junit.Test 7 | import org.junit.runner.RunWith 8 | 9 | /** 10 | * Instrumented test, which will execute on an Android device. 11 | * 12 | * See [testing documentation](http://d.android.com/tools/testing). 13 | */ 14 | @RunWith(AndroidJUnit4::class) 15 | class ExampleInstrumentedTest { 16 | @Test 17 | fun useAppContext() { 18 | // Context of the app under test. 19 | val appContext = InstrumentationRegistry.getInstrumentation().targetContext 20 | assertTrue(appContext.packageName.startsWith("com.jerboa")) 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /app/src/main/java/com/jerboa/feat/ImageProxySupport.kt: -------------------------------------------------------------------------------- 1 | package com.jerboa.feat 2 | 3 | import android.net.Uri 4 | 5 | const val V3_IMAGE_PROXY_PATH = "api/v3/image_proxy" 6 | const val V4_IMAGE_PROXY_PATH = "api/v4/image/proxy" 7 | 8 | fun isImageProxyEndpoint(uri: Uri): Boolean { 9 | val path = uri.path ?: return false 10 | return path.endsWith(V3_IMAGE_PROXY_PATH) || path.endsWith(V4_IMAGE_PROXY_PATH) 11 | } 12 | 13 | fun getProxiedImageUrl(uri: Uri): Uri { 14 | val query = uri.getQueryParameter("url") ?: return uri 15 | return Uri.parse(query) 16 | } 17 | 18 | fun String.parseUriWithProxyImageSupport(): Uri { 19 | val parsedUri = Uri.parse(this) 20 | return if (isImageProxyEndpoint(parsedUri)) { 21 | getProxiedImageUrl(parsedUri) 22 | } else { 23 | parsedUri 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/down_outline.xml: -------------------------------------------------------------------------------- 1 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /app/src/main/java/com/jerboa/datatypes/Utils.kt: -------------------------------------------------------------------------------- 1 | package com.jerboa.datatypes 2 | 3 | import androidx.compose.runtime.Composable 4 | import androidx.compose.ui.res.stringResource 5 | import com.jerboa.R 6 | import it.vercruysse.lemmyapi.datatypes.Comment 7 | import it.vercruysse.lemmyapi.datatypes.ImageDetails 8 | import it.vercruysse.lemmyapi.datatypes.Person 9 | 10 | fun Person.getDisplayName(): String = this.display_name ?: this.name 11 | 12 | @Composable 13 | fun Comment.getContent(): String = 14 | if (this.removed) { 15 | stringResource(R.string.comment_body_removed) 16 | } else if (this.deleted) { 17 | stringResource(R.string.comment_body_deleted) 18 | } else { 19 | this.content 20 | } 21 | 22 | fun ImageDetails.getAspectRatio(): Float? = this.width.toFloat() / this.height.toFloat() 23 | -------------------------------------------------------------------------------- /app/src/test/java/android/util/Log.java: -------------------------------------------------------------------------------- 1 | package android.util; 2 | 3 | /** 4 | * Mock of the Android Log class. 5 | *

6 | * This class is used to print log messages to the console when running unit tests. 7 | *

8 | */ 9 | public class Log { 10 | public static int d(String tag, String msg) { 11 | System.out.println("DEBUG: " + tag + ": " + msg); 12 | return 0; 13 | } 14 | 15 | public static int i(String tag, String msg) { 16 | System.out.println("INFO: " + tag + ": " + msg); 17 | return 0; 18 | } 19 | 20 | public static int w(String tag, String msg) { 21 | System.out.println("WARN: " + tag + ": " + msg); 22 | return 0; 23 | } 24 | 25 | public static int e(String tag, String msg) { 26 | System.out.println("ERROR: " + tag + ": " + msg); 27 | return 0; 28 | } 29 | } -------------------------------------------------------------------------------- /app/src/main/java/com/jerboa/feed/UniqueFeedController.kt: -------------------------------------------------------------------------------- 1 | package com.jerboa.feed 2 | 3 | import it.vercruysse.lemmyapi.Identity 4 | 5 | open class UniqueFeedController : FeedController() { 6 | private val ids = mutableSetOf() 7 | 8 | override fun add(item: T): Boolean { 9 | if (ids.add(item.id)) { 10 | items.add(item) 11 | return true 12 | } 13 | return false 14 | } 15 | 16 | override fun addAll(newItems: List) { 17 | newItems.forEach { 18 | if (ids.add(it.id)) { 19 | items.add(it) 20 | } 21 | } 22 | } 23 | 24 | override fun clear() { 25 | super.clear() 26 | ids.clear() 27 | } 28 | 29 | override fun remove(item: T): Boolean { 30 | ids.remove(item.id) 31 | return super.remove(item) 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /app/src/main/java/com/jerboa/util/markwon/ForceHttpsPlugin.kt: -------------------------------------------------------------------------------- 1 | package com.jerboa.util.markwon 2 | 3 | import com.jerboa.toHttps 4 | import io.noties.markwon.AbstractMarkwonPlugin 5 | import io.noties.markwon.MarkwonSpansFactory 6 | import io.noties.markwon.core.CoreProps 7 | import io.noties.markwon.core.spans.LinkSpan 8 | import org.commonmark.node.Link 9 | 10 | /** 11 | * A markwon plugin that rewrites all http:// links to https:// 12 | */ 13 | 14 | class ForceHttpsPlugin : AbstractMarkwonPlugin() { 15 | override fun configureSpansFactory(builder: MarkwonSpansFactory.Builder) { 16 | builder.setFactory( 17 | Link::class.java, 18 | ) { configuration, props -> 19 | LinkSpan( 20 | configuration.theme(), 21 | CoreProps.LINK_DESTINATION.require(props).toHttps(), 22 | configuration.linkResolver(), 23 | ) 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /scripts/generate_changelog.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | set -e 3 | 4 | pushd ../ 5 | # Creating the new tag and version code 6 | new_tag="$1" 7 | new_version_code="$2" 8 | github_token="$3" 9 | 10 | # Replacing the versions in the app/build.gradle.kts 11 | app_build_gradle="app/build.gradle.kts" 12 | sed -i "s/versionCode = .*/versionCode = $new_version_code/" $app_build_gradle 13 | sed -i "s/versionName = .*/versionName = \"$new_tag\"/" $app_build_gradle 14 | 15 | # Writing to the Releases.md asset that's loaded inside the app 16 | tmp_file="tmp_release.md" 17 | assets_releases="app/src/main/assets/RELEASES.md" 18 | git cliff --unreleased --tag "$new_tag" --output $tmp_file --github-token "$github_token" 19 | prettier -w $tmp_file 20 | 21 | cp $tmp_file $assets_releases 22 | 23 | # Adding to RELEASES.md 24 | git cliff --tag "$new_tag" --output RELEASES.md 25 | prettier -w RELEASES.md 26 | 27 | # Add them all to git 28 | git add $assets_releases $app_build_gradle RELEASES.md 29 | -------------------------------------------------------------------------------- /app/src/main/res/xml/locales_config.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/emergency_home_fill0_wght400_grad0_opsz48.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 10 | -------------------------------------------------------------------------------- /app/src/main/java/com/jerboa/ui/components/common/StateTriggers.kt: -------------------------------------------------------------------------------- 1 | package com.jerboa.ui.components.common 2 | 3 | import androidx.compose.foundation.lazy.LazyListState 4 | import androidx.compose.runtime.Composable 5 | import androidx.compose.runtime.LaunchedEffect 6 | import androidx.compose.runtime.derivedStateOf 7 | import androidx.compose.runtime.getValue 8 | import androidx.compose.runtime.remember 9 | import com.jerboa.isScrolledToEnd 10 | 11 | @Composable 12 | fun TriggerWhenReachingEnd( 13 | listState: LazyListState, 14 | showPostAppendRetry: Boolean, 15 | loadMorePosts: () -> Unit, 16 | ) { 17 | // observer when reached end of list 18 | val endOfListReached by remember { 19 | derivedStateOf { 20 | listState.isScrolledToEnd() 21 | } 22 | } 23 | 24 | // Act when end of list reached 25 | if (endOfListReached && !showPostAppendRetry) { 26 | LaunchedEffect(Unit) { 27 | loadMorePosts() 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /app/src/main/java/com/jerboa/ui/theme/Sizes.kt: -------------------------------------------------------------------------------- 1 | package com.jerboa.ui.theme 2 | 3 | import androidx.compose.ui.unit.dp 4 | 5 | const val DEFAULT_FONT_SIZE = 16 6 | 7 | val ACTION_BAR_ICON_SIZE = 12.dp 8 | val MARKDOWN_BAR_ICON_SIZE = 24.dp 9 | 10 | val BORDER_WIDTH = 1.dp 11 | val SMALLER_PADDING = 2.dp 12 | val SMALL_PADDING = 4.dp 13 | val MEDIUM_PADDING = 8.dp 14 | val LARGE_PADDING = 12.dp 15 | val XL_PADDING = 16.dp 16 | val XXL_PADDING = 20.dp 17 | 18 | val ICON_SIZE = 20.dp 19 | val LARGER_ICON_SIZE = 80.dp 20 | val DRAWER_BANNER_SIZE = 96.dp 21 | val PROFILE_BANNER_SIZE = 128.dp 22 | val LINK_ICON_SIZE = 36.dp 23 | val POST_LINK_PIC_SIZE = 70.dp 24 | val THUMBNAIL_CARET_SIZE = 10.dp 25 | 26 | val DRAWER_ITEM_SPACING = 24.dp 27 | 28 | val VERTICAL_SPACING = MEDIUM_PADDING 29 | 30 | // TODO remove all DPs from code, put here. 31 | const val ICON_THUMBNAIL_SIZE = 96 32 | const val LARGER_ICON_THUMBNAIL_SIZE = 256 33 | const val THUMBNAIL_SIZE = 256 34 | const val MAX_IMAGE_SIZE = 3000 35 | 36 | const val POPUP_MENU_WIDTH_RATIO = 0.86f 37 | -------------------------------------------------------------------------------- /app/src/main/java/com/jerboa/feat/BlurNSFW.kt: -------------------------------------------------------------------------------- 1 | package com.jerboa.feat 2 | 3 | import androidx.annotation.StringRes 4 | import com.jerboa.R 5 | import it.vercruysse.lemmyapi.datatypes.PostView 6 | 7 | enum class BlurNSFW( 8 | @param:StringRes val resId: Int, 9 | ) { 10 | Nothing(R.string.app_settings_nothing), 11 | NSFW(R.string.app_settings_blur_nsfw), 12 | NsfwExceptFromNsfwCommunities(R.string.app_settings_blur_nsfw_except_from_nsfw_communities), 13 | } 14 | 15 | fun BlurNSFW.changeBlurTypeInsideCommunity() = 16 | if (this == BlurNSFW.NsfwExceptFromNsfwCommunities) { 17 | BlurNSFW.Nothing 18 | } else { 19 | this 20 | } 21 | 22 | fun BlurNSFW.needBlur(postView: PostView) = this.needBlur(postView.community.nsfw, postView.post.nsfw) 23 | 24 | fun BlurNSFW.needBlur( 25 | isCommunityNsfw: Boolean, 26 | isPostNsfw: Boolean = isCommunityNsfw, 27 | ): Boolean = 28 | when (this) { 29 | BlurNSFW.Nothing -> false 30 | BlurNSFW.NSFW, BlurNSFW.NsfwExceptFromNsfwCommunities -> isPostNsfw 31 | } 32 | -------------------------------------------------------------------------------- /app/src/main/java/com/jerboa/util/downloadprogress/DownloadProgressInterceptor.kt: -------------------------------------------------------------------------------- 1 | package com.jerboa.util.downloadprogress 2 | 3 | import kotlinx.coroutines.flow.MutableStateFlow 4 | import okhttp3.Interceptor 5 | import okhttp3.Response 6 | import java.io.IOException 7 | 8 | class DownloadProgressInterceptor( 9 | private val downloadFlow: MutableStateFlow, 10 | ) : Interceptor { 11 | @Throws(IOException::class) 12 | override fun intercept(chain: Interceptor.Chain): Response { 13 | val originalResponse = chain.proceed(chain.request()) 14 | val responseBuilder = originalResponse.newBuilder() 15 | 16 | val downloadIdentifier = originalResponse.request.url.toString() 17 | 18 | val downloadResponseBody = originalResponse.body?.let { 19 | DownloadProgressResponseBody( 20 | downloadIdentifier, 21 | it, 22 | downloadFlow, 23 | ) 24 | } 25 | 26 | responseBuilder.body(downloadResponseBody) 27 | return responseBuilder.build() 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/changelogs/80.txt: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | ## What's Changed in 0.0.80 6 | 7 | - Fix post read not being applied in smallcard viewmode #1870 by @MV-GH in [#1872](https://github.com/LemmyNet/jerboa/pull/1872) 8 | - Fix crashes on Android 9 due to compose bump by @MV-GH in [#1871](https://github.com/LemmyNet/jerboa/pull/1871) 9 | - Fix LemmyAPI build by @MV-GH in [#1865](https://github.com/LemmyNet/jerboa/pull/1865) 10 | - Better ntfy notifs. by @dessalines in [#1853](https://github.com/LemmyNet/jerboa/pull/1853) 11 | - Fix edgecase saveImage failing causing crash by @MV-GH in [#1846](https://github.com/LemmyNet/jerboa/pull/1846) 12 | 13 | ## New Contributors 14 | 15 | - @flipflop97 made their first contribution 16 | - @ryanho made their first contribution 17 | - @Tmpod made their first contribution 18 | - @panosalevropoulos made their first contribution 19 | 20 | **Full Changelog**: https://github.com/LemmyNet/jerboa/compare/0.0.79...0.0.80 21 | 22 | 23 | -------------------------------------------------------------------------------- /benchmarks/src/main/java/com/jerboa/baselineprofile/StartupProfileGenerator.kt: -------------------------------------------------------------------------------- 1 | package com.jerboa.baselineprofile 2 | 3 | import androidx.benchmark.macro.junit4.BaselineProfileRule 4 | import androidx.test.ext.junit.runners.AndroidJUnit4 5 | import androidx.test.platform.app.InstrumentationRegistry 6 | import com.jerboa.actions.closeChangeLogIfOpen 7 | import com.jerboa.actions.waitUntilPostsActuallyVisible 8 | import org.junit.Rule 9 | import org.junit.Test 10 | import org.junit.runner.RunWith 11 | 12 | @RunWith(AndroidJUnit4::class) 13 | class StartupProfileGenerator { 14 | @get:Rule 15 | val baselineProfileRule = BaselineProfileRule() 16 | 17 | @Test 18 | fun startup() = 19 | baselineProfileRule.collect( 20 | packageName = InstrumentationRegistry.getArguments().getString("targetAppId") 21 | ?: throw Exception("targetAppId not passed as instrumentation runner arg"), 22 | includeInStartupProfile = true, 23 | ) { 24 | pressHome() 25 | startActivityAndWait() 26 | closeChangeLogIfOpen() 27 | waitUntilPostsActuallyVisible() 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /RESOURCES.md: -------------------------------------------------------------------------------- 1 | # Resources 2 | 3 | - [Architecture](https://developer.android.com/develop/ui/compose/architecture#udf) 4 | - [Compose coding design](https://android.googlesource.com/platform/frameworks/support/+/androidx-main/compose/docs/compose-api-guidelines.md#prefer-stateless-and-controlled-composable-functions) 5 | - [Modify MutableState class properties](https://stackoverflow.com/questions/63956058/jetpack-compose-state-modify-class-property) 6 | - [Introduction to the Compose Snapshot system](https://dev.to/zachklipp/introduction-to-the-compose-snapshot-system-19cn) 7 | - [Jetpack Compose: State](https://developer.android.com/jetpack/compose/state) 8 | - [Strong skipping](https://developer.android.com/develop/ui/compose/performance/stability/strongskipping), [2](https://medium.com/androiddevelopers/jetpack-compose-strong-skipping-mode-explained-cbdb2aa4b900) 9 | - [Material Design 3 docs](https://m3.material.io/) 10 | - [Jetpack Compose app Reply from compose-samples](https://github.com/android/compose-samples/tree/main/Reply) 11 | - [Design for android](https://developer.android.com/design/ui) 12 | - [Android Lint](https://developer.android.com/studio/write/lint) 13 | -------------------------------------------------------------------------------- /app/src/main/java/com/jerboa/db/repository/AppSettingsRepository.kt: -------------------------------------------------------------------------------- 1 | package com.jerboa.db.repository 2 | 3 | import androidx.annotation.WorkerThread 4 | import com.jerboa.db.dao.AppSettingsDao 5 | import com.jerboa.db.entity.AppSettings 6 | 7 | // Declares the DAO as a private property in the constructor. Pass in the DAO 8 | // instead of the whole database, because you only need access to the DAO 9 | class AppSettingsRepository( 10 | private val appSettingsDao: AppSettingsDao, 11 | ) { 12 | // Room executes all queries on a separate thread. 13 | // Observed Flow will notify the observer when the data has changed. 14 | val appSettings = appSettingsDao.getSettings() 15 | 16 | @WorkerThread 17 | suspend fun update(appSettings: AppSettings) { 18 | appSettingsDao.updateAppSettings(appSettings) 19 | } 20 | 21 | @WorkerThread 22 | suspend fun updateLastVersionCodeViewed(versionCode: Int) { 23 | appSettingsDao.updateLastVersionCode(versionCode) 24 | } 25 | 26 | @WorkerThread 27 | suspend fun updatePostViewMode(postViewMode: Int) { 28 | appSettingsDao.updatePostViewMode(postViewMode) 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.yaml: -------------------------------------------------------------------------------- 1 | name: 🚀 Feature Request 2 | description: Suggest an idea for this project. 3 | labels: ["enhancement", "triage"] 4 | body: 5 | - type: checkboxes 6 | id: terms 7 | attributes: 8 | label: Pre-Flight checklist 9 | description: Make sure these are correct before submitting 10 | options: 11 | - label: Did you check to see if this issue already exists? 12 | required: true 13 | - label: This is a single feature request. (Do not put multiple feature requests in one issue) 14 | required: true 15 | - label: This is not a question or discussion. (Use https://lemmy.ml/c/jerboa for that) 16 | required: true 17 | - type: textarea 18 | id: describefeaturerequest 19 | attributes: 20 | label: Describe The Feature Request Below 21 | description: A clear and concise description of what the feature request is. 22 | placeholder: | 23 | EXAMPLES: 24 | - Normally this happens, but that could happen instead 25 | - This functionality should be added 26 | - This functionality should be changed 27 | validations: 28 | required: true -------------------------------------------------------------------------------- /.run/Generate Baseline Profile.run.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 11 | 16 | 18 | true 19 | true 20 | false 21 | false 22 | 23 | 24 | -------------------------------------------------------------------------------- /app/src/main/java/com/jerboa/ui/components/common/Buttons.kt: -------------------------------------------------------------------------------- 1 | package com.jerboa.ui.components.common 2 | 3 | import androidx.compose.foundation.layout.fillMaxWidth 4 | import androidx.compose.foundation.layout.padding 5 | import androidx.compose.material3.Button 6 | import androidx.compose.material3.ButtonDefaults 7 | import androidx.compose.material3.MaterialTheme 8 | import androidx.compose.material3.Text 9 | import androidx.compose.runtime.Composable 10 | import androidx.compose.ui.Modifier 11 | import androidx.compose.ui.res.stringResource 12 | import com.jerboa.R 13 | import com.jerboa.ui.theme.XXL_PADDING 14 | 15 | @Composable 16 | fun RetryLoadingPosts(onClick: () -> Unit) { 17 | Button( 18 | modifier = 19 | Modifier 20 | .fillMaxWidth() 21 | .padding(horizontal = XXL_PADDING), 22 | onClick = onClick, 23 | colors = 24 | ButtonDefaults.buttonColors( 25 | containerColor = MaterialTheme.colorScheme.errorContainer, 26 | contentColor = MaterialTheme.colorScheme.onErrorContainer, 27 | ), 28 | ) { 29 | Text(stringResource(R.string.posts_failed_loading)) 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/matrix_favicon.xml: -------------------------------------------------------------------------------- 1 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /app/src/test/java/com/jerboa/ui/components/videoviewer/api/OpenGraphParserTest.kt: -------------------------------------------------------------------------------- 1 | package com.jerboa.ui.components.videoviewer.api 2 | 3 | import com.jerboa.ui.components.videoviewer.OpenGraphParser 4 | import org.junit.Assert.assertTrue 5 | import org.junit.Test 6 | 7 | class OpenGraphParserTest { 8 | private val sendVidSamplePage = loadResourceAsString("samples/sendvid-sample.html") 9 | 10 | @Test 11 | fun `should parse head from html page`() { 12 | val headContent = OpenGraphParser.parseHeadFromHtml(sendVidSamplePage) 13 | 14 | assertTrue(headContent != null) 15 | assertTrue(headContent!!.startsWith("")) 16 | } 17 | 18 | @Test 19 | fun `should find properties fields`() { 20 | val props = OpenGraphParser.findAllPropertiesFromHtml(sendVidSamplePage) 21 | 22 | assertTrue(props.isNotEmpty()) 23 | assertTrue(props.contains(Pair("og:video:type", "video/mp4"))) 24 | } 25 | 26 | fun loadResourceAsString(resourceName: String): String { 27 | val classLoader = javaClass.classLoader ?: ClassLoader.getSystemClassLoader() 28 | return classLoader.getResourceAsStream(resourceName)?.bufferedReader()?.use { it.readText() } 29 | ?: throw IllegalArgumentException("Resource $resourceName not found") 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /app/src/main/java/com/jerboa/feed/ApiActionController.kt: -------------------------------------------------------------------------------- 1 | package com.jerboa.feed 2 | 3 | import com.jerboa.api.ApiAction 4 | 5 | class ApiActionController( 6 | val idSelect: (T) -> Long, 7 | ) { 8 | private val controller = FeedController>() 9 | 10 | val feed: List> 11 | get() = controller.feed 12 | 13 | fun init(newItems: List) { 14 | controller.init(newItems.map { ApiAction.Ok(it) }) 15 | } 16 | 17 | fun setLoading(item: T) { 18 | controller.safeUpdate({ items -> 19 | items.indexOfFirst { idSelect(it.data) == idSelect(item) } 20 | }) { ApiAction.Loading(it.data) } 21 | } 22 | 23 | fun setOk(item: T) { 24 | controller.safeUpdate({ items -> 25 | items.indexOfFirst { idSelect(it.data) == idSelect(item) } 26 | }) { ApiAction.Ok(it.data) } 27 | } 28 | 29 | fun setFailed( 30 | item: T, 31 | error: Throwable, 32 | ) { 33 | controller.safeUpdate({ items -> 34 | items.indexOfFirst { idSelect(it.data) == idSelect(item) } 35 | }) { ApiAction.Failed(it.data, error) } 36 | } 37 | 38 | fun removeItem(item: T) { 39 | val index = feed.indexOfFirst { idSelect(it.data) == idSelect(item) } 40 | controller.removeAt(index) 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /app/src/main/java/com/jerboa/model/PostEditViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.jerboa.model 2 | 3 | import androidx.compose.runtime.getValue 4 | import androidx.compose.runtime.mutableStateOf 5 | import androidx.compose.runtime.setValue 6 | import androidx.lifecycle.ViewModel 7 | import androidx.lifecycle.viewModelScope 8 | import com.jerboa.api.API 9 | import com.jerboa.api.ApiState 10 | import com.jerboa.api.toApiState 11 | import it.vercruysse.lemmyapi.datatypes.EditPost 12 | import it.vercruysse.lemmyapi.datatypes.PostResponse 13 | import it.vercruysse.lemmyapi.datatypes.PostView 14 | import kotlinx.coroutines.launch 15 | 16 | class PostEditViewModel : ViewModel() { 17 | var editPostRes: ApiState by mutableStateOf(ApiState.Empty) 18 | private set 19 | 20 | fun editPost( 21 | form: EditPost, 22 | onSuccess: (PostView) -> Unit, 23 | ) { 24 | viewModelScope.launch { 25 | editPostRes = ApiState.Loading 26 | editPostRes = API.getInstance().editPost(form).toApiState() 27 | 28 | when (val res = editPostRes) { 29 | is ApiState.Success -> { 30 | val post = res.data.post_view 31 | onSuccess(post) 32 | } 33 | 34 | else -> {} 35 | } 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /app/src/main/java/com/jerboa/ui/components/common/AccountHelpers.kt: -------------------------------------------------------------------------------- 1 | package com.jerboa.ui.components.common 2 | 3 | import androidx.compose.runtime.Composable 4 | import androidx.compose.runtime.derivedStateOf 5 | import androidx.compose.runtime.getValue 6 | import androidx.compose.runtime.livedata.observeAsState 7 | import androidx.compose.runtime.remember 8 | import com.jerboa.PostViewMode 9 | import com.jerboa.db.entity.Account 10 | import com.jerboa.db.entity.AnonAccount 11 | import com.jerboa.getEnumFromIntSetting 12 | import com.jerboa.model.AccountViewModel 13 | import com.jerboa.model.AppSettingsViewModel 14 | 15 | /** 16 | * Returns the current Account or the AnonAccount if there is no set current Account 17 | */ 18 | @Composable 19 | fun getCurrentAccount(accountViewModel: AccountViewModel): Account { 20 | val currentAccount by accountViewModel.currentAccount.observeAsState() 21 | 22 | // DeriveState prevents unnecessary recompositions 23 | val acc by remember { 24 | derivedStateOf { currentAccount ?: AnonAccount } 25 | } 26 | 27 | return acc 28 | } 29 | 30 | fun getPostViewMode(appSettingsViewModel: AppSettingsViewModel): PostViewMode = 31 | getEnumFromIntSetting(appSettingsViewModel.appSettings) { 32 | it.postViewMode 33 | } 34 | 35 | val GuardAccount = AnonAccount.copy() 36 | -------------------------------------------------------------------------------- /app/src/main/java/com/jerboa/ui/components/community/sidebar/CommunitySidebar.kt: -------------------------------------------------------------------------------- 1 | package com.jerboa.ui.components.community.sidebar 2 | 3 | import androidx.compose.runtime.Composable 4 | import com.jerboa.ui.components.common.Sidebar 5 | import it.vercruysse.lemmyapi.datatypes.GetCommunityResponse 6 | import it.vercruysse.lemmyapi.datatypes.PersonId 7 | 8 | @Composable 9 | fun CommunitySidebar( 10 | communityRes: GetCommunityResponse, 11 | showAvatar: Boolean, 12 | onPersonClick: (PersonId) -> Unit, 13 | ) { 14 | val community = communityRes.community_view.community 15 | val counts = communityRes.community_view.counts 16 | Sidebar( 17 | title = community.title, 18 | content = community.description, 19 | banner = community.banner, 20 | icon = community.icon, 21 | published = community.published, 22 | usersActiveDay = counts.users_active_day, 23 | usersActiveWeek = counts.users_active_week, 24 | usersActiveMonth = counts.users_active_month, 25 | usersActiveHalfYear = counts.users_active_half_year, 26 | postCount = counts.posts, 27 | commentCount = counts.comments, 28 | moderators = communityRes.moderators, 29 | admins = emptyList(), 30 | showAvatar = showAvatar, 31 | onPersonClick = onPersonClick, 32 | ) 33 | } 34 | -------------------------------------------------------------------------------- /app/src/main/java/com/jerboa/ui/components/videoviewer/hosts/DirectFileVideoHost.kt: -------------------------------------------------------------------------------- 1 | package com.jerboa.ui.components.videoviewer.hosts 2 | 3 | import android.net.Uri 4 | import com.jerboa.ui.components.videoviewer.EmbeddedData 5 | 6 | class DirectFileVideoHost : SupportedVideoHost { 7 | companion object { 8 | private val videoExtensions: List = 9 | listOf("mp4", "mp3", "ogg", "flv", "m4a", "3gp", "mkv", "mpeg", "mov", "webm") 10 | 11 | fun isDirectUrl(url: String?): Boolean { 12 | if (url == null) return false 13 | val uri = Uri.parse(url) 14 | val lastPathSegment = uri.lastPathSegment ?: return false 15 | return videoExtensions.any { lastPathSegment.endsWith(".$it") } 16 | } 17 | } 18 | 19 | override fun isSupported(url: String): Boolean = isDirectUrl(url) 20 | 21 | override fun getVideoData(url: String): Result = 22 | Result.success( 23 | EmbeddedData( 24 | videoUrl = url, 25 | thumbnailUrl = null, 26 | typeName = getShortTypeName(), 27 | title = null, 28 | height = null, 29 | width = null, 30 | aspectRatio = 16f / 9f, 31 | ), 32 | ) 33 | 34 | override fun getShortTypeName() = "Video" 35 | } 36 | -------------------------------------------------------------------------------- /app/src/main/java/com/jerboa/model/PrivateMessageReplyViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.jerboa.model 2 | 3 | import androidx.compose.runtime.getValue 4 | import androidx.compose.runtime.mutableStateOf 5 | import androidx.compose.runtime.setValue 6 | import androidx.lifecycle.ViewModel 7 | import androidx.lifecycle.viewModelScope 8 | import com.jerboa.api.API 9 | import com.jerboa.api.ApiState 10 | import com.jerboa.api.toApiState 11 | import it.vercruysse.lemmyapi.datatypes.CreatePrivateMessage 12 | import it.vercruysse.lemmyapi.datatypes.PersonId 13 | import it.vercruysse.lemmyapi.datatypes.PrivateMessageResponse 14 | import kotlinx.coroutines.launch 15 | 16 | class PrivateMessageReplyViewModel : ViewModel() { 17 | var createMessageRes: ApiState by mutableStateOf(ApiState.Empty) 18 | private set 19 | 20 | fun createPrivateMessage( 21 | recipientId: PersonId, 22 | content: String, 23 | onGoBack: () -> Unit, 24 | ) { 25 | viewModelScope.launch { 26 | val form = 27 | CreatePrivateMessage( 28 | content = content, 29 | recipient_id = recipientId, 30 | ) 31 | createMessageRes = ApiState.Loading 32 | createMessageRes = API.getInstance().createPrivateMessage(form).toApiState() 33 | 34 | onGoBack() 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /app/src/main/assets/RELEASES.md: -------------------------------------------------------------------------------- 1 | ## What's Changed in 0.0.84 2 | 3 | - Restore deprecated apk post processing config by @MV-GH in [#1969](https://github.com/LemmyNet/jerboa/pull/1969) 4 | - Regenerate baseline profiles by @MV-GH in [#1968](https://github.com/LemmyNet/jerboa/pull/1968) 5 | - Fixing build tools to version 36.0.0 by @dessalines in [#1967](https://github.com/LemmyNet/jerboa/pull/1967) 6 | - Bump to Android SDK 36 by @MV-GH in [#1933](https://github.com/LemmyNet/jerboa/pull/1933) 7 | - Fix too large images in comments being cutoff by @MV-GH in [#1944](https://github.com/LemmyNet/jerboa/pull/1944) 8 | - Add option to disable video auto play by @MV-GH in [#1936](https://github.com/LemmyNet/jerboa/pull/1936) 9 | - Fix #1934 some urls being wrongly interpreted as video by @MV-GH in [#1941](https://github.com/LemmyNet/jerboa/pull/1941) 10 | - Merge branch 'main' of https://github.com/LemmyNet/jerboa by @dessalines 11 | - Update strings.xml by @jwkwshjsjsj in [#1928](https://github.com/LemmyNet/jerboa/pull/1928) 12 | - Update README.md by @jwkwshjsjsj in [#1926](https://github.com/LemmyNet/jerboa/pull/1926) 13 | 14 | ## New Contributors 15 | 16 | - @weblate made their first contribution 17 | - @jwkwshjsjsj made their first contribution in [#1928](https://github.com/LemmyNet/jerboa/pull/1928) 18 | 19 | **Full Changelog**: https://github.com/LemmyNet/jerboa/compare/0.0.83-gplay...0.0.84 20 | 21 | 22 | -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/changelogs/84.txt: -------------------------------------------------------------------------------- 1 | ## What's Changed in 0.0.84 2 | 3 | - Restore deprecated apk post processing config by @MV-GH in [#1969](https://github.com/LemmyNet/jerboa/pull/1969) 4 | - Regenerate baseline profiles by @MV-GH in [#1968](https://github.com/LemmyNet/jerboa/pull/1968) 5 | - Fixing build tools to version 36.0.0 by @dessalines in [#1967](https://github.com/LemmyNet/jerboa/pull/1967) 6 | - Bump to Android SDK 36 by @MV-GH in [#1933](https://github.com/LemmyNet/jerboa/pull/1933) 7 | - Fix too large images in comments being cutoff by @MV-GH in [#1944](https://github.com/LemmyNet/jerboa/pull/1944) 8 | - Add option to disable video auto play by @MV-GH in [#1936](https://github.com/LemmyNet/jerboa/pull/1936) 9 | - Fix #1934 some urls being wrongly interpreted as video by @MV-GH in [#1941](https://github.com/LemmyNet/jerboa/pull/1941) 10 | - Merge branch 'main' of https://github.com/LemmyNet/jerboa by @dessalines 11 | - Update strings.xml by @jwkwshjsjsj in [#1928](https://github.com/LemmyNet/jerboa/pull/1928) 12 | - Update README.md by @jwkwshjsjsj in [#1926](https://github.com/LemmyNet/jerboa/pull/1926) 13 | 14 | ## New Contributors 15 | 16 | - @weblate made their first contribution 17 | - @jwkwshjsjsj made their first contribution in [#1928](https://github.com/LemmyNet/jerboa/pull/1928) 18 | 19 | **Full Changelog**: https://github.com/LemmyNet/jerboa/compare/0.0.83-gplay...0.0.84 20 | 21 | 22 | -------------------------------------------------------------------------------- /app/src/androidTest/java/com/jerboa/MigrationsTest.kt: -------------------------------------------------------------------------------- 1 | package com.jerboa 2 | 3 | import androidx.room.Room.databaseBuilder 4 | import androidx.room.testing.MigrationTestHelper 5 | import androidx.test.ext.junit.runners.AndroidJUnit4 6 | import androidx.test.platform.app.InstrumentationRegistry 7 | import com.jerboa.db.AppDB 8 | import com.jerboa.db.MIGRATIONS_LIST 9 | import org.junit.Rule 10 | import org.junit.Test 11 | import org.junit.runner.RunWith 12 | import java.io.IOException 13 | 14 | @RunWith(AndroidJUnit4::class) 15 | class MigrationsTest { 16 | private val testDB = "migration-test" 17 | 18 | @get:Rule 19 | val helper: MigrationTestHelper = 20 | MigrationTestHelper( 21 | InstrumentationRegistry.getInstrumentation(), 22 | AppDB::class.java, 23 | ) 24 | 25 | @Test 26 | @Throws(IOException::class) 27 | fun migrateAll() { 28 | // Create earliest version of the database. 29 | helper.createDatabase(testDB, 1).apply { 30 | close() 31 | } 32 | 33 | // Open latest version of the database. Room validates the schema 34 | // once all migrations execute. 35 | databaseBuilder( 36 | InstrumentationRegistry.getInstrumentation().targetContext, 37 | AppDB::class.java, 38 | testDB, 39 | ).addMigrations(*MIGRATIONS_LIST).build().apply { 40 | openHelper.writableDatabase.close() 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /app/src/main/res/values-zh-rTW/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 顯示推數 4 | 刪除 5 | 載入中 6 | 顯示噓數 7 | 顯示推票比例 8 | 已鎖定 9 | 還原 10 | 已按讚 11 | 保存 12 | 申請人 13 | 批准 14 | 本地精選 15 | 社群精選 16 | 檢舉人 17 | 距離到期天數 18 | 19 | 標題 20 | 檢舉 21 | 隱藏貼文 22 | 解除隱藏貼文 23 | 貼文已隱藏 24 | 貼文已取消隱藏 25 | 贊助 26 | 作者 27 | 移除貼文 28 | 鎖定貼文 29 | 解除鎖定貼文 30 | 分享貼文 31 | 檢視原始碼 32 | -------------------------------------------------------------------------------- /gradle.properties: -------------------------------------------------------------------------------- 1 | # Project-wide Gradle settings. 2 | # IDE (e.g. Android Studio) users: 3 | # Gradle settings configured through the IDE *will override* 4 | # any settings specified in this file. 5 | # For more details on how to configure your build environment visit 6 | # http://www.gradle.org/docs/current/userguide/build_environment.html 7 | # Specifies the JVM arguments used for the daemon process. 8 | # The setting is particularly useful for tweaking memory settings. 9 | org.gradle.jvmargs=-Xmx2g -Dfile.encoding=UTF-8 10 | # When configured, Gradle will run in incubating parallel mode. 11 | # This option should only be used with decoupled projects. More details, visit 12 | # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects 13 | # org.gradle.parallel=true 14 | # AndroidX package structure to make it clearer which packages are bundled with the 15 | # Android operating system, and which are packaged with your app"s APK 16 | # https://developer.android.com/topic/libraries/support-library/androidx-rn 17 | android.useAndroidX=true 18 | # Kotlin code style for this project: "official" or "obsolete": 19 | kotlin.code.style=official 20 | # Enables namespacing of each library's R class so that its R class includes only the 21 | # resources declared in the library itself and none from the library's dependencies, 22 | # thereby reducing the size of the R class for that library 23 | android.nonTransitiveRClass=true 24 | android.nonFinalResIds=false 25 | org.gradle.configuration-cache=true -------------------------------------------------------------------------------- /app/src/main/java/com/jerboa/ui/components/common/Image.kt: -------------------------------------------------------------------------------- 1 | package com.jerboa.ui.components.common 2 | 3 | import android.os.Build 4 | import androidx.compose.runtime.Composable 5 | import androidx.compose.runtime.remember 6 | import androidx.compose.ui.Modifier 7 | import androidx.compose.ui.layout.ContentScale 8 | import androidx.compose.ui.platform.LocalContext 9 | import coil.compose.AsyncImage 10 | import coil.request.ImageRequest 11 | import com.jerboa.util.BlurTransformation 12 | 13 | @Composable 14 | fun AsyncImageWithBlur( 15 | url: String, 16 | blur: Boolean, 17 | modifier: Modifier = Modifier, 18 | contentScale: ContentScale, 19 | contentDescription: String? = null, 20 | ) { 21 | val context = LocalContext.current 22 | 23 | val builder = remember { 24 | var temp = ImageRequest 25 | .Builder(context) 26 | .data(url) 27 | .crossfade(true) 28 | 29 | if (blur && Build.VERSION.SDK_INT < 31) { 30 | temp = temp.transformations( 31 | listOf( 32 | BlurTransformation( 33 | scale = 0.5f, 34 | radius = 100, 35 | ), 36 | ), 37 | ) 38 | } 39 | temp.build() 40 | } 41 | 42 | AsyncImage( 43 | model = builder, 44 | contentDescription = contentDescription, 45 | contentScale = contentScale, 46 | modifier = modifier.getBlurredOrRounded(blur = blur), 47 | ) 48 | } 49 | -------------------------------------------------------------------------------- /app/src/main/java/com/jerboa/ui/components/videoviewer/hosts/jerboa-video-hosts-research.md: -------------------------------------------------------------------------------- 1 | # Research on video hosts 2 | 3 | streamable: 4 | - has OG 5 | - Has Thumbnail 6 | - Has vid 7 | - has width + height 8 | - both have same ratio 9 | - Seems to have expire part of url 10 | - has ld-json 11 | - Expiry seems to be ignored 12 | - example: https://streamable.com/gjs6hc 13 | 14 | Conclusion: Rely on default embedded behaviour 15 | 16 | sendvid: 17 | - has OG 18 | - has image but no thumbnail 19 | - has vid 20 | - with + height 21 | - short 2h expiry in url 22 | - no ld-json 23 | 24 | Conclusion: Custom implementation that just does OGP parsing 25 | 26 | redgifs: 27 | - has OG 28 | - no thumbnail in OG, but seems it can be "guessed" -poster.jpg 29 | - has vid 30 | - has width + height 31 | - No expiry part of url 32 | - has ld-json 33 | 34 | Conclusion: Use API to get video info 35 | 36 | reddit vid: 37 | 38 | Conclusion: Too complex to figure out, much information is wrong, leave as is 39 | 40 | vimeo: 41 | - has og 42 | - has image 43 | - width + height 44 | - has vid + but not direct link to FILE, links to iframe stuff 45 | - no expiry 46 | - no ld-json 47 | - example: https://vimeo.com/156881088 48 | 49 | oembed endpoint https://vimeo.com/api/oembed.json?url=https://vimeo.com/156881088 50 | player endpoint https://player.vimeo.com/video/$id 51 | 52 | Conclusion: Not supported 53 | 54 | peertube: 55 | - has og 56 | - has image 57 | - has vid + but link to html/js blob 58 | - no expiry 59 | - no ld-json 60 | 61 | Conclusion: Not supported -------------------------------------------------------------------------------- /app/src/main/java/com/jerboa/ui/components/home/sidebar/SiteSidebar.kt: -------------------------------------------------------------------------------- 1 | package com.jerboa.ui.components.home.sidebar 2 | 3 | import androidx.compose.runtime.Composable 4 | import androidx.compose.ui.tooling.preview.Preview 5 | import com.jerboa.datatypes.sampleGetSiteRes 6 | import com.jerboa.ui.components.common.Sidebar 7 | import it.vercruysse.lemmyapi.datatypes.GetSiteResponse 8 | import it.vercruysse.lemmyapi.datatypes.PersonId 9 | 10 | @Composable 11 | fun SiteSidebar( 12 | siteRes: GetSiteResponse, 13 | showAvatar: Boolean, 14 | onPersonClick: (PersonId) -> Unit, 15 | ) { 16 | val site = siteRes.site_view.site 17 | val counts = siteRes.site_view.counts 18 | Sidebar( 19 | title = site.description, 20 | banner = site.banner, 21 | icon = site.icon, 22 | content = site.sidebar, 23 | published = site.published, 24 | postCount = counts.posts, 25 | commentCount = counts.comments, 26 | usersActiveDay = counts.users_active_day, 27 | usersActiveWeek = counts.users_active_week, 28 | usersActiveMonth = counts.users_active_month, 29 | usersActiveHalfYear = counts.users_active_half_year, 30 | showAvatar = showAvatar, 31 | onPersonClick = onPersonClick, 32 | admins = siteRes.admins, 33 | moderators = emptyList(), 34 | ) 35 | } 36 | 37 | @Preview 38 | @Composable 39 | fun SiteSidebarPreview() { 40 | SiteSidebar( 41 | siteRes = sampleGetSiteRes, 42 | onPersonClick = {}, 43 | showAvatar = false, 44 | ) 45 | } 46 | -------------------------------------------------------------------------------- /app/src/main/java/com/jerboa/ui/components/remove/RemoveItem.kt: -------------------------------------------------------------------------------- 1 | package com.jerboa.ui.components.remove 2 | 3 | import androidx.compose.foundation.layout.Column 4 | import androidx.compose.foundation.layout.PaddingValues 5 | import androidx.compose.foundation.layout.consumeWindowInsets 6 | import androidx.compose.foundation.layout.imePadding 7 | import androidx.compose.foundation.layout.padding 8 | import androidx.compose.foundation.rememberScrollState 9 | import androidx.compose.foundation.verticalScroll 10 | import androidx.compose.runtime.Composable 11 | import androidx.compose.ui.Modifier 12 | import androidx.compose.ui.res.stringResource 13 | import androidx.compose.ui.text.input.TextFieldValue 14 | import com.jerboa.R 15 | import com.jerboa.db.entity.Account 16 | import com.jerboa.ui.components.common.MarkdownTextField 17 | 18 | @Composable 19 | fun RemoveItemBody( 20 | reason: TextFieldValue, 21 | onReasonChange: (TextFieldValue) -> Unit, 22 | account: Account, 23 | padding: PaddingValues, 24 | ) { 25 | val scrollState = rememberScrollState() 26 | 27 | Column( 28 | modifier = 29 | Modifier 30 | .verticalScroll(scrollState) 31 | .padding(padding) 32 | .consumeWindowInsets(padding) 33 | .imePadding(), 34 | ) { 35 | MarkdownTextField( 36 | text = reason, 37 | onTextChange = onReasonChange, 38 | account = account, 39 | placeholder = stringResource(R.string.type_your_reason), 40 | ) 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /app/src/main/java/com/jerboa/ui/components/report/CreateReport.kt: -------------------------------------------------------------------------------- 1 | package com.jerboa.ui.components.report 2 | 3 | import androidx.compose.foundation.layout.Column 4 | import androidx.compose.foundation.layout.PaddingValues 5 | import androidx.compose.foundation.layout.consumeWindowInsets 6 | import androidx.compose.foundation.layout.imePadding 7 | import androidx.compose.foundation.layout.padding 8 | import androidx.compose.foundation.rememberScrollState 9 | import androidx.compose.foundation.verticalScroll 10 | import androidx.compose.runtime.Composable 11 | import androidx.compose.ui.Modifier 12 | import androidx.compose.ui.res.stringResource 13 | import androidx.compose.ui.text.input.TextFieldValue 14 | import com.jerboa.R 15 | import com.jerboa.db.entity.Account 16 | import com.jerboa.ui.components.common.MarkdownTextField 17 | 18 | @Composable 19 | fun CreateReportBody( 20 | reason: TextFieldValue, 21 | onReasonChange: (TextFieldValue) -> Unit, 22 | account: Account, 23 | padding: PaddingValues, 24 | ) { 25 | val scrollState = rememberScrollState() 26 | 27 | Column( 28 | modifier = 29 | Modifier 30 | .verticalScroll(scrollState) 31 | .padding(padding) 32 | .consumeWindowInsets(padding) 33 | .imePadding(), 34 | ) { 35 | MarkdownTextField( 36 | text = reason, 37 | onTextChange = onReasonChange, 38 | account = account, 39 | placeholder = stringResource(R.string.create_report_type_your_reason), 40 | ) 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /app/src/main/java/com/jerboa/ui/components/comment/edit/CommentEdit.kt: -------------------------------------------------------------------------------- 1 | 2 | package com.jerboa.ui.components.comment.edit 3 | 4 | import androidx.compose.foundation.layout.Column 5 | import androidx.compose.foundation.layout.PaddingValues 6 | import androidx.compose.foundation.layout.consumeWindowInsets 7 | import androidx.compose.foundation.layout.imePadding 8 | import androidx.compose.foundation.layout.padding 9 | import androidx.compose.foundation.rememberScrollState 10 | import androidx.compose.foundation.verticalScroll 11 | import androidx.compose.runtime.Composable 12 | import androidx.compose.ui.Modifier 13 | import androidx.compose.ui.res.stringResource 14 | import androidx.compose.ui.text.input.TextFieldValue 15 | import com.jerboa.R 16 | import com.jerboa.db.entity.Account 17 | import com.jerboa.ui.components.common.MarkdownTextField 18 | 19 | @Composable 20 | fun CommentEdit( 21 | content: TextFieldValue, 22 | onContentChange: (TextFieldValue) -> Unit, 23 | account: Account, 24 | padding: PaddingValues, 25 | ) { 26 | val scrollState = rememberScrollState() 27 | 28 | Column( 29 | modifier = 30 | Modifier 31 | .verticalScroll(scrollState) 32 | .padding(padding) 33 | .consumeWindowInsets(padding) 34 | .imePadding(), 35 | ) { 36 | MarkdownTextField( 37 | text = content, 38 | onTextChange = onContentChange, 39 | account = account, 40 | placeholder = stringResource(R.string.comment_edit_type_your_comment), 41 | ) 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /app/proguard-rules.pro: -------------------------------------------------------------------------------- 1 | # Add project specific ProGuard rules here. 2 | # You can control the set of applied configuration files using the 3 | # proguardFiles setting in build.gradle.kts. 4 | # 5 | # For more details, see 6 | # http://developer.android.com/guide/developing/tools/proguard.html 7 | 8 | # If your project uses WebView with JS, uncomment the following 9 | # and specify the fully qualified class name to the JavaScript interface 10 | # class: 11 | #-keepclassmembers class fqcn.of.javascript.interface.for.webview { 12 | # public *; 13 | #} 14 | 15 | # Uncomment this to preserve the line number information for 16 | # debugging stack traces. 17 | #-keepattributes SourceFile,LineNumberTable 18 | 19 | # If you keep the line number information, uncomment this to 20 | # hide the original source file name. 21 | #-renamesourcefileattribute SourceFile 22 | 23 | # With R8 full mode generic signatures are stripped for classes that are not 24 | # kept. Suspend functions are wrapped in continuations where the type argument 25 | # is used. 26 | -keep,allowobfuscation,allowshrinking class kotlin.coroutines.Continuation 27 | 28 | 29 | # Some prettytime stuff 30 | -keep class com.ocpsoft.pretty.time.i18n.** 31 | -keep class org.ocpsoft.prettytime.i18n.** 32 | -keepnames class ** implements org.ocpsoft.prettytime.TimeUnit 33 | 34 | # Ktor needs this 35 | -dontwarn org.slf4j.impl.StaticLoggerBinder 36 | 37 | # Until https://issuetracker.google.com/issues/425120571 is fixed 38 | -keepclassmembers class androidx.compose.ui.graphics.layer.view.ViewLayerContainer { 39 | protected void dispatchGetDisplayList(); 40 | } -------------------------------------------------------------------------------- /app/src/main/java/com/jerboa/ui/components/common/ApiStateHelpers.kt: -------------------------------------------------------------------------------- 1 | package com.jerboa.ui.components.common 2 | 3 | import android.content.Context 4 | import android.util.Log 5 | import android.widget.Toast 6 | import androidx.compose.foundation.layout.PaddingValues 7 | import androidx.compose.foundation.layout.padding 8 | import androidx.compose.material3.MaterialTheme 9 | import androidx.compose.material3.Text 10 | import androidx.compose.runtime.Composable 11 | import androidx.compose.ui.Modifier 12 | import com.jerboa.api.ApiState 13 | 14 | @Composable 15 | fun ApiErrorText( 16 | msg: Throwable, 17 | paddingValues: PaddingValues = PaddingValues(), 18 | ) { 19 | msg.message?.also { 20 | ApiErrorText(it, paddingValues = paddingValues) 21 | } 22 | } 23 | 24 | @Composable 25 | fun ApiErrorText( 26 | msg: String, 27 | modifier: Modifier = Modifier, 28 | paddingValues: PaddingValues = PaddingValues(), 29 | ) { 30 | Text( 31 | text = msg, 32 | modifier = modifier.padding(paddingValues), 33 | color = MaterialTheme.colorScheme.onError, 34 | ) 35 | } 36 | 37 | fun apiErrorToast( 38 | ctx: Context, 39 | msg: Throwable, 40 | ) { 41 | msg.message?.also { 42 | Log.e("apiErrorToast", it) 43 | Toast.makeText(ctx, it, Toast.LENGTH_SHORT).show() 44 | } 45 | } 46 | 47 | @Composable 48 | fun ApiEmptyText() { 49 | Text("Empty") 50 | } 51 | 52 | fun ApiState.isLoading(): Boolean = this is ApiState.Appending || this == ApiState.Loading || this == ApiState.Refreshing 53 | 54 | fun ApiState.isRefreshing(): Boolean = this == ApiState.Refreshing 55 | -------------------------------------------------------------------------------- /app/src/main/java/com/jerboa/feed/PostController.kt: -------------------------------------------------------------------------------- 1 | package com.jerboa.feed 2 | 3 | import com.jerboa.datatypes.BanFromCommunityData 4 | import it.vercruysse.lemmyapi.datatypes.HidePost 5 | import it.vercruysse.lemmyapi.datatypes.Person 6 | import it.vercruysse.lemmyapi.datatypes.PostView 7 | 8 | open class PostController : UniqueFeedController() { 9 | fun findAndUpdatePost(updatedPostView: PostView) { 10 | safeUpdate({ posts -> 11 | posts.indexOfFirst { 12 | it.post.id == updatedPostView.post.id 13 | } 14 | }) { updatedPostView } 15 | } 16 | 17 | fun findAndUpdateCreator(person: Person) { 18 | updateAll( 19 | { it.indexesOf { postView -> postView.creator.id == person.id } }, 20 | ) { it.copy(creator = person) } 21 | } 22 | 23 | fun findAndUpdatePostCreatorBannedFromCommunity(banData: BanFromCommunityData) { 24 | updateAll( 25 | { 26 | it.indexesOf { postView -> 27 | postView.creator.id == banData.person.id && postView.community.id == banData.community.id 28 | } 29 | }, 30 | ) { it.copy(banned_from_community = banData.banned, creator_banned_from_community = banData.banned, creator = banData.person) } 31 | } 32 | 33 | fun findAndUpdatePostHidden(hidePost: HidePost) { 34 | updateAll( 35 | { 36 | it.indexesOf { postView -> 37 | hidePost.post_ids.contains(postView.post.id) 38 | } 39 | }, 40 | ) { it.copy(hidden = hidePost.hide) } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /app/src/main/java/com/jerboa/util/markwon/ScriptRewriteSupportPlugin.kt: -------------------------------------------------------------------------------- 1 | package com.jerboa.util.markwon 2 | 3 | import io.noties.markwon.AbstractMarkwonPlugin 4 | 5 | class ScriptRewriteSupportPlugin : AbstractMarkwonPlugin() { 6 | override fun processMarkdown(markdown: String): String = 7 | super.processMarkdown( 8 | if (markdown.contains("^") || markdown.contains("~")) { 9 | rewriteLemmyScriptToMarkwonScript(markdown) 10 | } else { // Fast path: if there are no markdown characters, we don't need to do anything 11 | markdown 12 | }, 13 | ) 14 | 15 | companion object { 16 | /* 17 | * Superscript has the definition: 18 | * Any text between a '^' that is not interrupted by a linebreak where the starting 19 | * or ending text can't be a whitespace character. 20 | */ 21 | val SUPERSCRIPT_RGX = Regex("""\^(?!\s)([^\n^]+)(?$1") 34 | .replace(SUBSCRIPT_RGX, "$1") 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /.run/Generate Baseline Profile (show display).run.xml: -------------------------------------------------------------------------------- 1 | 7 | 8 | 9 | 10 | 17 | 24 | 26 | true 27 | true 28 | false 29 | false 30 | 31 | 32 | -------------------------------------------------------------------------------- /app/src/main/java/com/jerboa/ui/components/common/SwipeToNavigateBack.kt: -------------------------------------------------------------------------------- 1 | package com.jerboa.ui.components.common 2 | 3 | import androidx.compose.foundation.background 4 | import androidx.compose.foundation.layout.Box 5 | import androidx.compose.foundation.layout.fillMaxSize 6 | import androidx.compose.material3.MaterialTheme 7 | import androidx.compose.material3.SwipeToDismissBox 8 | import androidx.compose.material3.SwipeToDismissBoxValue 9 | import androidx.compose.material3.rememberSwipeToDismissBoxState 10 | import androidx.compose.runtime.Composable 11 | import androidx.compose.ui.Modifier 12 | import com.jerboa.feat.PostNavigationGestureMode 13 | 14 | @Composable 15 | fun SwipeToNavigateBack( 16 | useSwipeBack: PostNavigationGestureMode, 17 | onSwipeBack: () -> Unit, 18 | content: @Composable () -> Unit, 19 | ) { 20 | if (useSwipeBack == PostNavigationGestureMode.SwipeRight) { 21 | val swipeState = rememberSwipeToDismissBoxState() 22 | 23 | when (swipeState.currentValue) { 24 | SwipeToDismissBoxValue.StartToEnd -> { 25 | onSwipeBack() 26 | } 27 | 28 | else -> { 29 | } 30 | } 31 | 32 | SwipeToDismissBox( 33 | state = swipeState, 34 | enableDismissFromEndToStart = false, 35 | backgroundContent = { 36 | Box( 37 | modifier = Modifier 38 | .fillMaxSize() 39 | .background(MaterialTheme.colorScheme.background), 40 | ) 41 | }, 42 | ) { 43 | content() 44 | } 45 | } else { 46 | content() 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /app/src/main/java/com/jerboa/ui/components/videoviewer/VideoHostComposer.kt: -------------------------------------------------------------------------------- 1 | package com.jerboa.ui.components.videoviewer 2 | 3 | import android.util.Log 4 | import com.jerboa.ui.components.videoviewer.hosts.DirectFileVideoHost 5 | import com.jerboa.ui.components.videoviewer.hosts.RedgifsVideoHost 6 | import com.jerboa.ui.components.videoviewer.hosts.SendvidVideoHost 7 | import kotlinx.coroutines.Dispatchers 8 | import kotlinx.coroutines.withContext 9 | 10 | class VideoHostComposer { 11 | companion object { 12 | private val lruCache = object : LinkedHashMap?>(200, 0.75f, true) { 13 | override fun removeEldestEntry(eldest: MutableMap.MutableEntry?>): Boolean { 14 | return size > 200 // Limit cache to 200 entries 15 | } 16 | } 17 | 18 | val instances = listOf( 19 | DirectFileVideoHost(), 20 | RedgifsVideoHost(), 21 | SendvidVideoHost(), 22 | ) 23 | 24 | fun isVideo(url: String): Boolean = instances.any { it.isSupported(url) } 25 | 26 | suspend fun getVideoData(url: String): Result { 27 | Log.d("VideoHostComposer", "Getting video data for $url") 28 | if (lruCache.containsKey(url)) return lruCache[url]!! 29 | 30 | val data = withContext(Dispatchers.IO) { 31 | return@withContext instances.first { it.isSupported(url) }.getVideoData(url) 32 | } 33 | 34 | lruCache[url] = data 35 | return data 36 | } 37 | 38 | fun getVideoDataFromCache(url: String): Result? = lruCache[url] 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /app/src/main/java/com/jerboa/util/downloadprogress/DownloadProgressResponseBody.kt: -------------------------------------------------------------------------------- 1 | package com.jerboa.util.downloadprogress 2 | 3 | import kotlinx.coroutines.flow.MutableStateFlow 4 | import okhttp3.MediaType 5 | import okhttp3.ResponseBody 6 | import okio.* 7 | import java.io.IOException 8 | 9 | class DownloadProgressResponseBody( 10 | val downloadIdentifier: String, 11 | val responseBody: ResponseBody, 12 | val downloadFlow: MutableStateFlow, 13 | ) : ResponseBody() { 14 | private lateinit var bufferedSource: BufferedSource 15 | 16 | override fun contentLength(): Long = responseBody.contentLength() 17 | 18 | override fun contentType(): MediaType? = responseBody.contentType() 19 | 20 | override fun source(): BufferedSource { 21 | if (!this::bufferedSource.isInitialized) { 22 | bufferedSource = getForwardSource(responseBody.source()).buffer() 23 | } 24 | return bufferedSource 25 | } 26 | 27 | private fun getForwardSource(source: Source): Source = 28 | object : ForwardingSource(source) { 29 | var totalBytesRead = 0L 30 | 31 | @Throws(IOException::class) 32 | override fun read( 33 | sink: Buffer, 34 | byteCount: Long, 35 | ): Long { 36 | val bytesRead = super.read(sink, byteCount) 37 | // read() returns the number of bytes read, or -1 if this source is exhausted. 38 | totalBytesRead += if (bytesRead != -1L) bytesRead else 0 39 | downloadFlow.tryEmit(ProgressEvent(downloadIdentifier, responseBody.contentLength(), totalBytesRead)) 40 | return bytesRead 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /app/src/main/java/com/jerboa/model/CommentEditViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.jerboa.model 2 | 3 | import androidx.compose.runtime.getValue 4 | import androidx.compose.runtime.mutableStateOf 5 | import androidx.compose.runtime.setValue 6 | import androidx.compose.ui.focus.FocusManager 7 | import androidx.lifecycle.ViewModel 8 | import androidx.lifecycle.viewModelScope 9 | import com.jerboa.api.API 10 | import com.jerboa.api.ApiState 11 | import com.jerboa.api.toApiState 12 | import it.vercruysse.lemmyapi.datatypes.CommentResponse 13 | import it.vercruysse.lemmyapi.datatypes.CommentView 14 | import it.vercruysse.lemmyapi.datatypes.EditComment 15 | import kotlinx.coroutines.launch 16 | 17 | class CommentEditViewModel : ViewModel() { 18 | var editCommentRes: ApiState by mutableStateOf(ApiState.Empty) 19 | private set 20 | 21 | fun editComment( 22 | commentView: CommentView, 23 | content: String, 24 | focusManager: FocusManager, 25 | onSuccess: (CommentView) -> Unit, 26 | ) { 27 | viewModelScope.launch { 28 | val form = 29 | EditComment( 30 | content = content, 31 | comment_id = commentView.comment.id, 32 | ) 33 | 34 | editCommentRes = ApiState.Loading 35 | editCommentRes = API.getInstance().editComment(form).toApiState() 36 | focusManager.clearFocus() 37 | 38 | // Update all the views which might have your comment 39 | when (val res = editCommentRes) { 40 | is ApiState.Success -> { 41 | onSuccess(res.data.comment_view) 42 | } 43 | 44 | else -> {} 45 | } 46 | } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /app/src/main/java/com/jerboa/ui/components/common/Modifiers.kt: -------------------------------------------------------------------------------- 1 | package com.jerboa.ui.components.common 2 | 3 | import android.os.Build 4 | import androidx.compose.foundation.basicMarquee 5 | import androidx.compose.foundation.shape.RoundedCornerShape 6 | import androidx.compose.ui.Modifier 7 | import androidx.compose.ui.draw.blur 8 | import androidx.compose.ui.draw.clip 9 | import androidx.compose.ui.draw.drawWithContent 10 | import androidx.compose.ui.graphics.BlendMode 11 | import androidx.compose.ui.graphics.Brush 12 | import androidx.compose.ui.graphics.CompositingStrategy 13 | import androidx.compose.ui.graphics.graphicsLayer 14 | import androidx.compose.ui.unit.dp 15 | 16 | inline fun Modifier.ifDo( 17 | predicate: Boolean, 18 | modifier: Modifier.() -> Modifier, 19 | ): Modifier = if (predicate) modifier() else this 20 | 21 | inline fun Modifier.ifNotNull( 22 | value: T?, 23 | modifier: Modifier.(_: T) -> Modifier, 24 | ): Modifier = if (value != null) modifier(value) else this 25 | 26 | fun Modifier.getBlurredOrRounded( 27 | blur: Boolean, 28 | rounded: Boolean = false, 29 | ): Modifier { 30 | var lModifier = this 31 | 32 | if (rounded) { 33 | lModifier = lModifier.clip(RoundedCornerShape(12f)) 34 | } 35 | if (blur && Build.VERSION.SDK_INT >= 31) { 36 | lModifier = lModifier.blur(radius = 100.dp) 37 | } 38 | return lModifier 39 | } 40 | 41 | fun Modifier.customMarquee(): Modifier = this.basicMarquee(initialDelayMillis = 4_000) 42 | 43 | fun Modifier.fadingEdge(brush: Brush) = 44 | this 45 | .graphicsLayer(compositingStrategy = CompositingStrategy.Offscreen) 46 | .drawWithContent { 47 | drawContent() 48 | drawRect(brush = brush, blendMode = BlendMode.DstIn) 49 | } 50 | -------------------------------------------------------------------------------- /app/src/androidTest/java/com/jerboa/ui/components/videoviewer/hosts/DirectFileVideoHostTest.kt: -------------------------------------------------------------------------------- 1 | package com.jerboa.ui.components.videoviewer.hosts 2 | 3 | import androidx.test.ext.junit.runners.AndroidJUnit4 4 | import org.junit.Assert 5 | import org.junit.Test 6 | import org.junit.runner.RunWith 7 | 8 | @RunWith(AndroidJUnit4::class) 9 | class DirectFileVideoHostTest { 10 | @Test 11 | fun testIsDirectUrl() { 12 | val validUrls = listOf( 13 | "http://example.com/video.mp4", 14 | "https://example.com/video.mp4", 15 | "http://example.com/video.mkv", 16 | "https://example.com/video.mkv", 17 | "http://example.com/path/to/video.mp4", 18 | "https://example.com/path/to/video.mp4", 19 | "https://example.com/path/to/video.mp4?query=123", 20 | ) 21 | 22 | val invalidUrls = listOf( 23 | "http://example.com/video.avi", 24 | "https://example.com/video.movx", 25 | "http://example.com/path/to/video.mp4x", 26 | "https://example.com/path/to/video.mp3x", 27 | "http://example.com/video", 28 | "https://example.com/video.", 29 | "not a url", 30 | "", 31 | null, 32 | "https://www.move.org", 33 | "https://www.move.org/", 34 | "https://www.move.org/index.html", 35 | "https://www.moveslowlybuildbridges.com/index.html", 36 | ) 37 | 38 | validUrls.forEach { url -> 39 | Assert.assertTrue("Expected valid for URL: $url", DirectFileVideoHost.isDirectUrl(url)) 40 | } 41 | 42 | invalidUrls.forEach { url -> 43 | Assert.assertFalse("Expected invalid for URL: $url", DirectFileVideoHost.isDirectUrl(url)) 44 | } 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /app/src/main/java/com/jerboa/db/dao/AccountDao.kt: -------------------------------------------------------------------------------- 1 | package com.jerboa.db.dao 2 | 3 | import androidx.lifecycle.LiveData 4 | import androidx.room.Dao 5 | import androidx.room.Delete 6 | import androidx.room.Insert 7 | import androidx.room.OnConflictStrategy 8 | import androidx.room.Query 9 | import androidx.room.Transaction 10 | import androidx.room.Update 11 | import com.jerboa.db.entity.Account 12 | 13 | @Dao 14 | interface AccountDao { 15 | @Query("SELECT * FROM account") 16 | fun getAll(): LiveData> 17 | 18 | @Query("SELECT * FROM account where current = 1 limit 1") 19 | fun getCurrent(): LiveData 20 | 21 | @Query("SELECT * FROM account where current = 1 limit 1") 22 | suspend fun getCurrentAsync(): Account? 23 | 24 | @Insert(onConflict = OnConflictStrategy.IGNORE, entity = Account::class) 25 | suspend fun insert(account: Account) 26 | 27 | @Update(entity = Account::class) 28 | suspend fun update(account: Account) 29 | 30 | @Query("UPDATE account set current = 0 where current = 1") 31 | suspend fun removeCurrent() 32 | 33 | @Query("UPDATE account set current = 1 where id = :accountId") 34 | suspend fun setCurrent(accountId: Long) 35 | 36 | // Important to use this instead of calling removeCurrent and setCurrent manually 37 | // Because the previous would cause livedata to emit null then newAccount 38 | @Transaction 39 | suspend fun updateCurrent(accountId: Long) { 40 | removeCurrent() 41 | setCurrent(accountId) 42 | } 43 | 44 | @Query("Update account set verification_state = :state where id = :accountId") 45 | suspend fun setVerificationState( 46 | accountId: Long, 47 | state: Int, 48 | ) 49 | 50 | @Delete(entity = Account::class) 51 | suspend fun delete(account: Account) 52 | } 53 | -------------------------------------------------------------------------------- /app/src/main/java/com/jerboa/model/CreatePostViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.jerboa.model 2 | 3 | import androidx.compose.runtime.getValue 4 | import androidx.compose.runtime.mutableStateOf 5 | import androidx.compose.runtime.setValue 6 | import androidx.lifecycle.ViewModel 7 | import androidx.lifecycle.viewModelScope 8 | import com.jerboa.api.API 9 | import com.jerboa.api.ApiState 10 | import com.jerboa.api.toApiState 11 | import it.vercruysse.lemmyapi.datatypes.CreatePost 12 | import it.vercruysse.lemmyapi.datatypes.GetSiteMetadata 13 | import it.vercruysse.lemmyapi.datatypes.GetSiteMetadataResponse 14 | import it.vercruysse.lemmyapi.datatypes.PostId 15 | import it.vercruysse.lemmyapi.datatypes.PostResponse 16 | import kotlinx.coroutines.launch 17 | 18 | class CreatePostViewModel : ViewModel() { 19 | var createPostRes: ApiState by mutableStateOf(ApiState.Empty) 20 | private set 21 | var siteMetadataRes: ApiState by mutableStateOf(ApiState.Empty) 22 | private set 23 | 24 | fun createPost( 25 | form: CreatePost, 26 | onSuccess: (postId: PostId) -> Unit, 27 | ) { 28 | viewModelScope.launch { 29 | createPostRes = ApiState.Loading 30 | createPostRes = API.getInstance().createPost(form).toApiState() 31 | 32 | when (val postRes = createPostRes) { 33 | is ApiState.Success -> { 34 | onSuccess(postRes.data.post_view.post.id) 35 | } 36 | 37 | else -> {} 38 | } 39 | } 40 | } 41 | 42 | fun getSiteMetadata(form: GetSiteMetadata) { 43 | viewModelScope.launch { 44 | siteMetadataRes = ApiState.Loading 45 | siteMetadataRes = API.getInstance().getSiteMetadata(form).toApiState() 46 | } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /app/src/main/java/com/jerboa/db/repository/AccountRepository.kt: -------------------------------------------------------------------------------- 1 | package com.jerboa.db.repository 2 | 3 | import androidx.annotation.WorkerThread 4 | import com.jerboa.db.dao.AccountDao 5 | import com.jerboa.db.entity.Account 6 | 7 | // Declares the DAO as a private property in the constructor. Pass in the DAO 8 | // instead of the whole database, because you only need access to the DAO 9 | class AccountRepository( 10 | private val accountDao: AccountDao, 11 | ) { 12 | // Room executes all queries on a separate thread. 13 | // Observed Flow will notify the observer when the data has changed. 14 | val currentAccount = accountDao.getCurrent() 15 | val allAccounts = accountDao.getAll() 16 | 17 | // By default Room runs suspend queries off the main thread, therefore, we don't need to 18 | // implement anything else to ensure we're not doing long running database work 19 | // off the main thread. 20 | @WorkerThread 21 | suspend fun insert(account: Account) { 22 | accountDao.insert(account) 23 | } 24 | 25 | @WorkerThread 26 | suspend fun update(account: Account) { 27 | accountDao.update(account) 28 | } 29 | 30 | @WorkerThread 31 | suspend fun removeCurrent() { 32 | accountDao.removeCurrent() 33 | } 34 | 35 | @WorkerThread 36 | suspend fun setVerificationState( 37 | accountId: Long, 38 | state: Int, 39 | ) { 40 | accountDao.setVerificationState(accountId, state) 41 | } 42 | 43 | @WorkerThread 44 | suspend fun delete(account: Account) { 45 | accountDao.delete(account) 46 | } 47 | 48 | @WorkerThread 49 | suspend fun updateCurrent(accountId: Long) { 50 | accountDao.updateCurrent(accountId) 51 | } 52 | 53 | @WorkerThread 54 | suspend fun getCurrentAsync(): Account? = accountDao.getCurrentAsync() 55 | } 56 | -------------------------------------------------------------------------------- /.woodpecker.yml: -------------------------------------------------------------------------------- 1 | variables: 2 | - &android_image "cimg/android:2025.12" 3 | 4 | steps: 5 | prettier_markdown_check: 6 | image: jauderho/prettier:3.7.4-alpine 7 | commands: 8 | - prettier -c "*.md" "*.yml" 9 | when: 10 | - event: pull_request 11 | 12 | check_formatting: 13 | image: *android_image 14 | commands: 15 | - sudo chown -R circleci:circleci . 16 | - ./gradlew lintKotlin 17 | environment: 18 | GRADLE_USER_HOME: ".gradle" 19 | when: 20 | - event: pull_request 21 | 22 | check_android_lint: 23 | image: *android_image 24 | commands: 25 | - sudo chown -R circleci:circleci . 26 | - ./gradlew lint 27 | environment: 28 | GRADLE_USER_HOME: ".gradle" 29 | when: 30 | - event: pull_request 31 | 32 | build_project: 33 | image: *android_image 34 | commands: 35 | - sudo chown -R circleci:circleci . 36 | - ./gradlew assembleRelease 37 | environment: 38 | GRADLE_USER_HOME: ".gradle" 39 | when: 40 | - event: pull_request 41 | 42 | run_tests: 43 | image: *android_image 44 | commands: 45 | - sudo chown -R circleci:circleci . 46 | - ./gradlew testDebug 47 | environment: 48 | GRADLE_USER_HOME: ".gradle" 49 | when: 50 | - event: pull_request 51 | 52 | notify_success: 53 | image: alpine:3 54 | commands: 55 | - apk add curl 56 | - "curl -H'Title: ✔️ ${CI_REPO_NAME}/${CI_COMMIT_SOURCE_BRANCH}' -d'${CI_PIPELINE_URL}' ntfy.sh/jerboa_ci" 57 | when: 58 | - event: pull_request 59 | status: [success] 60 | 61 | notify_failure: 62 | image: alpine:3 63 | commands: 64 | - apk add curl 65 | - "curl -H'Title: ❌ ${CI_REPO_NAME}/${CI_COMMIT_SOURCE_BRANCH}' -d'${CI_PIPELINE_URL}' ntfy.sh/jerboa_ci" 66 | when: 67 | - event: pull_request 68 | status: [failure] 69 | -------------------------------------------------------------------------------- /app/schemas/com.jerboa.db.AppDB/1.json: -------------------------------------------------------------------------------- 1 | { 2 | "formatVersion": 1, 3 | "database": { 4 | "version": 1, 5 | "identityHash": "b0ee8d528541e5a9089ad6b2cc3d7570", 6 | "entities": [ 7 | { 8 | "tableName": "Account", 9 | "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `current` INTEGER NOT NULL, `instance` TEXT NOT NULL, `name` TEXT NOT NULL, `jwt` TEXT NOT NULL, PRIMARY KEY(`id`))", 10 | "fields": [ 11 | { 12 | "fieldPath": "id", 13 | "columnName": "id", 14 | "affinity": "INTEGER", 15 | "notNull": true 16 | }, 17 | { 18 | "fieldPath": "current", 19 | "columnName": "current", 20 | "affinity": "INTEGER", 21 | "notNull": true 22 | }, 23 | { 24 | "fieldPath": "instance", 25 | "columnName": "instance", 26 | "affinity": "TEXT", 27 | "notNull": true 28 | }, 29 | { 30 | "fieldPath": "name", 31 | "columnName": "name", 32 | "affinity": "TEXT", 33 | "notNull": true 34 | }, 35 | { 36 | "fieldPath": "jwt", 37 | "columnName": "jwt", 38 | "affinity": "TEXT", 39 | "notNull": true 40 | } 41 | ], 42 | "primaryKey": { 43 | "columnNames": [ 44 | "id" 45 | ], 46 | "autoGenerate": false 47 | }, 48 | "indices": [], 49 | "foreignKeys": [] 50 | } 51 | ], 52 | "views": [], 53 | "setupQueries": [ 54 | "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", 55 | "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'b0ee8d528541e5a9089ad6b2cc3d7570')" 56 | ] 57 | } 58 | } -------------------------------------------------------------------------------- /app/src/androidTest/java/com/jerboa/feat/ImageProxySupportTest.kt: -------------------------------------------------------------------------------- 1 | package com.jerboa.feat 2 | 3 | import android.net.Uri 4 | import androidx.test.ext.junit.runners.AndroidJUnit4 5 | import org.junit.Assert 6 | import org.junit.Test 7 | import org.junit.runner.RunWith 8 | 9 | @RunWith(AndroidJUnit4::class) 10 | class ImageProxySupportTest { 11 | @Test 12 | fun shouldIdentifyImageProxyEndpoints() { 13 | listOf( 14 | listOf( 15 | "https://example.com/api/v3/image_proxy", 16 | true, 17 | ), 18 | listOf( 19 | "https://example.com/api/v4/image/proxy", 20 | true, 21 | ), 22 | listOf( 23 | "https://example.com/api/v1/some_other_endpoint", 24 | false, 25 | ), 26 | listOf( 27 | "https://lemmy.ml/api/v3/image_proxy?url=https%3A%2F%2Flemmy.world%2Fpictrs%2Fimage%2Fb78339bf-95ab-4a61-b9ab-bb67696b2a4d.webp", 28 | true, 29 | ), 30 | ).forEach { 31 | val input = it[0] as String 32 | val expected = it[1] as Boolean 33 | val result = isImageProxyEndpoint(Uri.parse(input)) 34 | Assert.assertEquals(expected, result) 35 | } 36 | } 37 | 38 | @Test 39 | fun shouldExtractProxiedImageUrl() { 40 | listOf( 41 | listOf( 42 | "https://lemmy.ml/api/v3/image_proxy?url=https%3A%2F%2Flemmy.world%2Fpictrs%2Fimage%2Fb78339bf-95ab-4a61-b9ab-bb67696b2a4d.webp", 43 | "https://lemmy.world/pictrs/image/b78339bf-95ab-4a61-b9ab-bb67696b2a4d.webp", 44 | ), 45 | ).forEach { 46 | val input = it[0] 47 | val expected = it[1] 48 | val result = getProxiedImageUrl(Uri.parse(input)) 49 | Assert.assertEquals(expected, result.toString()) 50 | } 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /app/src/main/java/com/jerboa/db/entity/Account.kt: -------------------------------------------------------------------------------- 1 | package com.jerboa.db.entity 2 | 3 | import androidx.room.ColumnInfo 4 | import androidx.room.Entity 5 | import androidx.room.PrimaryKey 6 | import com.jerboa.datatypes.UserViewType 7 | import com.jerboa.feat.AccountVerificationState 8 | 9 | @Entity 10 | data class Account( 11 | @PrimaryKey val id: Long, 12 | @ColumnInfo(name = "current") val current: Boolean, 13 | @ColumnInfo(name = "instance") val instance: String, 14 | @ColumnInfo(name = "name") val name: String, 15 | @ColumnInfo(name = "jwt") val jwt: String, 16 | @ColumnInfo( 17 | name = "default_listing_type", 18 | defaultValue = "0", 19 | ) 20 | val defaultListingType: Int, 21 | @ColumnInfo( 22 | name = "default_sort_type", 23 | defaultValue = "0", 24 | ) 25 | val defaultSortType: Int, 26 | @ColumnInfo( 27 | name = "verification_state", 28 | defaultValue = "0", 29 | ) 30 | val verificationState: Int, 31 | // These two are used to show extra bottom bar items right away 32 | @ColumnInfo(name = "is_admin") val isAdmin: Boolean, 33 | @ColumnInfo(name = "is_mod") val isMod: Boolean, 34 | ) 35 | 36 | val AnonAccount = 37 | Account( 38 | id = -1, 39 | current = true, 40 | instance = "", 41 | name = "Anonymous", 42 | jwt = "", 43 | defaultListingType = 1, 44 | defaultSortType = 0, 45 | verificationState = 0, 46 | isAdmin = false, 47 | isMod = false, 48 | ) 49 | 50 | fun Account.isAnon(): Boolean = this.id == -1L 51 | 52 | fun Account.isReady(): Boolean = this.verificationState == AccountVerificationState.CHECKS_COMPLETE.ordinal 53 | 54 | fun Account.userViewType(): UserViewType = 55 | if (isAdmin) { 56 | UserViewType.AdminOnly 57 | } else if (isMod) { 58 | UserViewType.AdminOrMod 59 | } else { 60 | UserViewType.Normal 61 | } 62 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/error_placeholder.xml: -------------------------------------------------------------------------------- 1 | 6 | 12 | 17 | 23 | 28 | 34 | 40 | 45 | 46 | -------------------------------------------------------------------------------- /benchmarks/src/main/java/com/jerboa/benchmarks/ScrollPostsBenchmarks.kt: -------------------------------------------------------------------------------- 1 | package com.jerboa.benchmarks 2 | 3 | import androidx.benchmark.macro.BaselineProfileMode 4 | import androidx.benchmark.macro.CompilationMode 5 | import androidx.benchmark.macro.FrameTimingMetric 6 | import androidx.benchmark.macro.StartupMode 7 | import androidx.benchmark.macro.junit4.MacrobenchmarkRule 8 | import androidx.test.ext.junit.runners.AndroidJUnit4 9 | import androidx.test.filters.LargeTest 10 | import androidx.test.platform.app.InstrumentationRegistry 11 | import com.jerboa.actions.closeChangeLogIfOpen 12 | import com.jerboa.actions.scrollThroughPosts 13 | import com.jerboa.actions.waitUntilPostsActuallyVisible 14 | import org.junit.Rule 15 | import org.junit.Test 16 | import org.junit.runner.RunWith 17 | 18 | @RunWith(AndroidJUnit4::class) 19 | @LargeTest 20 | class ScrollPostsBenchmarks { 21 | @get:Rule 22 | val rule = MacrobenchmarkRule() 23 | 24 | @Test 25 | fun scrollPostsCompilationNone() = benchmark(CompilationMode.None()) 26 | 27 | @Test 28 | fun scrollPostsCompilationBaselineProfiles() = benchmark(CompilationMode.Partial(BaselineProfileMode.Require)) 29 | 30 | private fun benchmark(compilationMode: CompilationMode) { 31 | rule.measureRepeated( 32 | packageName = InstrumentationRegistry.getArguments().getString("targetAppId") 33 | ?: throw Exception("targetAppId not passed as instrumentation runner arg"), 34 | metrics = listOf(FrameTimingMetric()), 35 | compilationMode = compilationMode, 36 | startupMode = StartupMode.WARM, 37 | iterations = 5, 38 | setupBlock = { 39 | pressHome() 40 | startActivityAndWait() 41 | closeChangeLogIfOpen() 42 | waitUntilPostsActuallyVisible() 43 | }, 44 | measureBlock = { 45 | scrollThroughPosts() 46 | }, 47 | ) 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /app/src/main/java/com/jerboa/ui/components/community/sidebar/CommunitySidebarScreen.kt: -------------------------------------------------------------------------------- 1 | package com.jerboa.ui.components.community.sidebar 2 | 3 | import android.util.Log 4 | import androidx.compose.foundation.layout.Box 5 | import androidx.compose.foundation.layout.padding 6 | import androidx.compose.material3.ExperimentalMaterial3Api 7 | import androidx.compose.material3.Scaffold 8 | import androidx.compose.runtime.Composable 9 | import androidx.compose.ui.Modifier 10 | import androidx.compose.ui.res.stringResource 11 | import com.jerboa.JerboaAppState 12 | import com.jerboa.R 13 | import com.jerboa.hostName 14 | import com.jerboa.ui.components.common.SimpleTopAppBar 15 | import it.vercruysse.lemmyapi.datatypes.GetCommunityResponse 16 | 17 | object CommunityViewSidebar { 18 | const val COMMUNITY_RES = "side-bar::return(community-res)" 19 | } 20 | 21 | @OptIn(ExperimentalMaterial3Api::class) 22 | @Composable 23 | fun CommunitySidebarScreen( 24 | appState: JerboaAppState, 25 | onClickBack: () -> Unit, 26 | showAvatar: Boolean, 27 | ) { 28 | Log.d("jerboa", "got to community sidebar screen") 29 | val communityRes = appState.getPrevReturn(CommunityViewSidebar.COMMUNITY_RES) 30 | val community = communityRes.community_view.community 31 | 32 | Scaffold( 33 | topBar = { 34 | SimpleTopAppBar( 35 | text = 36 | stringResource( 37 | R.string.actionbar_info_header, 38 | community.name, 39 | hostName(community.actor_id) ?: "invalid_actor_id", 40 | ), 41 | onClickBack = onClickBack, 42 | ) 43 | }, 44 | content = { padding -> 45 | Box(modifier = Modifier.padding(padding)) { 46 | CommunitySidebar( 47 | communityRes = communityRes, 48 | onPersonClick = appState::toProfile, 49 | showAvatar = showAvatar, 50 | ) 51 | } 52 | }, 53 | ) 54 | } 55 | -------------------------------------------------------------------------------- /app/src/main/java/com/jerboa/JerboaApplication.kt: -------------------------------------------------------------------------------- 1 | package com.jerboa 2 | 3 | import android.app.Application 4 | import android.os.Build 5 | import coil.ImageLoader 6 | import coil.ImageLoaderFactory 7 | import coil.decode.GifDecoder 8 | import coil.decode.ImageDecoderDecoder 9 | import coil.decode.SvgDecoder 10 | import coil.decode.VideoFrameDecoder 11 | import com.jerboa.api.API 12 | import com.jerboa.db.AppDBContainer 13 | import com.jerboa.util.downloadprogress.DownloadProgress 14 | 15 | class JerboaApplication : 16 | Application(), 17 | ImageLoaderFactory { 18 | lateinit var container: AppDBContainer 19 | lateinit var imageViewerLoader: ImageLoader 20 | private lateinit var imageLoader: ImageLoader 21 | lateinit var imageGifLoader: ImageLoader 22 | 23 | override fun onCreate() { 24 | super.onCreate() 25 | 26 | container = AppDBContainer(this) 27 | imageLoader = 28 | ImageLoader 29 | .Builder(this) 30 | .okHttpClient(API.httpClient) 31 | .crossfade(true) 32 | .error(R.drawable.error_placeholder) 33 | .placeholder(R.drawable.ic_launcher_mono) 34 | .components { 35 | add(SvgDecoder.Factory()) 36 | add(VideoFrameDecoder.Factory()) 37 | }.build() 38 | 39 | imageGifLoader = 40 | imageLoader 41 | .newBuilder() 42 | .components { 43 | add(SvgDecoder.Factory()) 44 | if (Build.VERSION.SDK_INT >= 28) { 45 | add(ImageDecoderDecoder.Factory()) 46 | } else { 47 | add(GifDecoder.Factory()) 48 | } 49 | }.build() 50 | 51 | imageViewerLoader = 52 | imageGifLoader 53 | .newBuilder() 54 | .okHttpClient(DownloadProgress.downloadProgressHttpClient) 55 | .build() 56 | } 57 | 58 | override fun newImageLoader(): ImageLoader = imageLoader 59 | } 60 | -------------------------------------------------------------------------------- /benchmarks/src/main/java/com/jerboa/benchmarks/TypicalUserJourneyBenchmarks.kt: -------------------------------------------------------------------------------- 1 | package com.jerboa.benchmarks 2 | 3 | import androidx.benchmark.macro.BaselineProfileMode 4 | import androidx.benchmark.macro.CompilationMode 5 | import androidx.benchmark.macro.FrameTimingMetric 6 | import androidx.benchmark.macro.StartupMode 7 | import androidx.benchmark.macro.junit4.MacrobenchmarkRule 8 | import androidx.test.ext.junit.runners.AndroidJUnit4 9 | import androidx.test.filters.LargeTest 10 | import androidx.test.platform.app.InstrumentationRegistry 11 | import com.jerboa.actions.closeChangeLogIfOpen 12 | import com.jerboa.actions.doTypicalUserJourney 13 | import com.jerboa.actions.waitUntilLoadingDone 14 | import com.jerboa.actions.waitUntilPostsActuallyVisible 15 | import org.junit.Rule 16 | import org.junit.Test 17 | import org.junit.runner.RunWith 18 | 19 | @RunWith(AndroidJUnit4::class) 20 | @LargeTest 21 | class TypicalUserJourneyBenchmarks { 22 | @get:Rule 23 | val rule = MacrobenchmarkRule() 24 | 25 | @Test 26 | fun startUserJourneyCompilationNone() = benchmark(CompilationMode.None()) 27 | 28 | @Test 29 | fun startUserJourneyCompilationBaselineProfiles() = benchmark(CompilationMode.Partial(BaselineProfileMode.Require)) 30 | 31 | private fun benchmark(compilationMode: CompilationMode) { 32 | rule.measureRepeated( 33 | packageName = InstrumentationRegistry.getArguments().getString("targetAppId") 34 | ?: throw Exception("targetAppId not passed as instrumentation runner arg"), 35 | metrics = listOf(FrameTimingMetric()), 36 | compilationMode = compilationMode, 37 | startupMode = StartupMode.WARM, 38 | iterations = 5, 39 | setupBlock = { 40 | pressHome() 41 | startActivityAndWait() 42 | closeChangeLogIfOpen() 43 | waitUntilLoadingDone() 44 | waitUntilPostsActuallyVisible() 45 | }, 46 | measureBlock = { 47 | doTypicalUserJourney() 48 | }, 49 | ) 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /app/src/main/java/com/jerboa/model/AppSettingsViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.jerboa.model 2 | 3 | import android.content.Context 4 | import android.util.Log 5 | import androidx.compose.runtime.Stable 6 | import androidx.compose.runtime.getValue 7 | import androidx.compose.runtime.mutableStateOf 8 | import androidx.compose.runtime.setValue 9 | import androidx.lifecycle.ViewModel 10 | import androidx.lifecycle.viewModelScope 11 | import androidx.lifecycle.viewmodel.initializer 12 | import androidx.lifecycle.viewmodel.viewModelFactory 13 | import com.jerboa.db.entity.AppSettings 14 | import com.jerboa.db.repository.AppSettingsRepository 15 | import com.jerboa.jerboaApplication 16 | import kotlinx.coroutines.launch 17 | 18 | @Stable 19 | class AppSettingsViewModel( 20 | private val repository: AppSettingsRepository, 21 | ) : ViewModel() { 22 | val appSettings = repository.appSettings 23 | var changelog by mutableStateOf("") 24 | 25 | fun update(appSettings: AppSettings) = 26 | viewModelScope.launch { 27 | repository.update(appSettings) 28 | } 29 | 30 | fun updateLastVersionCodeViewed(versionCode: Int) = 31 | viewModelScope.launch { 32 | repository.updateLastVersionCodeViewed(versionCode) 33 | } 34 | 35 | fun updatedPostViewMode(postViewMode: Int) = 36 | viewModelScope.launch { 37 | repository.updatePostViewMode(postViewMode) 38 | } 39 | 40 | fun loadChangelog(ctx: Context) = 41 | viewModelScope.launch { 42 | try { 43 | Log.d("jerboa", "Getting RELEASES.md from assets...") 44 | changelog = ctx.assets 45 | .open("RELEASES.md") 46 | .bufferedReader() 47 | .use { it.readText() } 48 | } catch (e: Exception) { 49 | Log.e("jerboa", "Failed to load changelog: $e") 50 | } 51 | } 52 | } 53 | 54 | object AppSettingsViewModelFactory { 55 | val Factory = 56 | viewModelFactory { 57 | initializer { 58 | AppSettingsViewModel(jerboaApplication().container.appSettingsRepository) 59 | } 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /benchmarks/src/main/java/com/jerboa/Utils.kt: -------------------------------------------------------------------------------- 1 | package com.jerboa 2 | 3 | import androidx.test.uiautomator.By 4 | import androidx.test.uiautomator.Direction 5 | import androidx.test.uiautomator.StaleObjectException 6 | import androidx.test.uiautomator.UiDevice 7 | import androidx.test.uiautomator.UiObject2 8 | import androidx.test.uiautomator.Until 9 | 10 | fun UiObject2.scrollThrough( 11 | qDown: Int = 10, 12 | qUp: Int = 5, 13 | ) { 14 | try { 15 | repeat(qDown) { 16 | this.fling(Direction.DOWN) 17 | } 18 | repeat(qUp) { 19 | this.fling(Direction.UP) 20 | } 21 | // Sometimes the element becomes stale, almost guaranteed when a upfling causes a refresh 22 | } catch (_: StaleObjectException) { 23 | } 24 | } 25 | 26 | fun UiObject2.scrollThroughShort() { 27 | this.scrollThrough(5, 2) 28 | } 29 | 30 | fun UiDevice.findOrFail( 31 | resId: String, 32 | failMsg: String, 33 | ): UiObject2 = this.findObject(By.res(resId)) ?: throw IllegalStateException(failMsg) 34 | 35 | fun UiDevice.findOrFail(resId: String): UiObject2 = this.findOrFail(resId, "$resId not found") 36 | 37 | fun UiObject2.findOrFail( 38 | resId: String, 39 | failMsg: String, 40 | ): UiObject2 = this.findObject(By.res(resId)) ?: throw IllegalStateException(failMsg) 41 | 42 | fun UiDevice.findOrFailTimeout(resId: String): UiObject2 = this.findOrFailTimeout(resId, "$resId not found") 43 | 44 | fun UiDevice.findOrFailTimeout( 45 | resId: String, 46 | failMsg: String, 47 | timeout: Long = 5000, 48 | ): UiObject2 = findTimeout(resId, timeout) ?: throw IllegalStateException(failMsg) 49 | 50 | fun UiDevice.findTimeout( 51 | resId: String, 52 | timeout: Long = 5000, 53 | ): UiObject2? = wait(Until.findObject(By.res(resId)), timeout) 54 | 55 | // Somehow you can have device.findObject().click() be instantly Stale 56 | // This is an attempt at solving that 57 | fun UiDevice.retryOnStale( 58 | element: UiObject2, 59 | resId: String, 60 | self: (UiObject2) -> Unit, 61 | ) { 62 | try { 63 | self(element) 64 | } catch (_: StaleObjectException) { 65 | this.retryOnStale(this.findOrFailTimeout(resId), resId, self) 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /app/src/main/java/com/jerboa/ui/components/common/PopupItems.kt: -------------------------------------------------------------------------------- 1 | package com.jerboa.ui.components.common 2 | 3 | import androidx.compose.material.icons.Icons 4 | import androidx.compose.material.icons.outlined.Gavel 5 | import androidx.compose.material.icons.outlined.Restore 6 | import androidx.compose.runtime.Composable 7 | import androidx.compose.ui.res.stringResource 8 | import com.jerboa.R 9 | import com.jerboa.communityNameShown 10 | import com.jerboa.datatypes.BanFromCommunityData 11 | import com.jerboa.personNameShown 12 | import it.vercruysse.lemmyapi.datatypes.Person 13 | 14 | @Composable 15 | fun BanPersonPopupMenuItem( 16 | person: Person, 17 | onDismissRequest: () -> Unit, 18 | onBanPersonClick: (person: Person) -> Unit, 19 | ) { 20 | val personNameShown = personNameShown(person, true) 21 | val (banText, banIcon) = 22 | if (person.banned) { 23 | Pair(stringResource(R.string.unban_person, personNameShown), Icons.Outlined.Restore) 24 | } else { 25 | Pair(stringResource(R.string.ban_person, personNameShown), Icons.Outlined.Gavel) 26 | } 27 | 28 | PopupMenuItem( 29 | text = banText, 30 | icon = banIcon, 31 | onClick = { 32 | onDismissRequest() 33 | onBanPersonClick(person) 34 | }, 35 | ) 36 | } 37 | 38 | @Composable 39 | fun BanFromCommunityPopupMenuItem( 40 | banData: BanFromCommunityData, 41 | onDismissRequest: () -> Unit, 42 | onBanFromCommunityClick: (banData: BanFromCommunityData) -> Unit, 43 | ) { 44 | val personNameShown = personNameShown(banData.person, true) 45 | val communityNameShown = communityNameShown(banData.community) 46 | val (banText, banIcon) = 47 | if (banData.banned) { 48 | Pair(stringResource(R.string.unban_person_from_community, personNameShown, communityNameShown), Icons.Outlined.Restore) 49 | } else { 50 | Pair(stringResource(R.string.ban_person_from_community, personNameShown, communityNameShown), Icons.Outlined.Gavel) 51 | } 52 | 53 | PopupMenuItem( 54 | text = banText, 55 | icon = banIcon, 56 | onClick = { 57 | onDismissRequest() 58 | onBanFromCommunityClick(banData) 59 | }, 60 | ) 61 | } 62 | -------------------------------------------------------------------------------- /app/src/test/java/com/jerboa/ExampleUnitTest.kt: -------------------------------------------------------------------------------- 1 | package com.jerboa 2 | 3 | import com.jerboa.api.API 4 | import com.jerboa.api.DEFAULT_INSTANCE 5 | import it.vercruysse.lemmyapi.datatypes.GetPost 6 | import it.vercruysse.lemmyapi.datatypes.GetPosts 7 | import it.vercruysse.lemmyapi.dto.ListingType 8 | import it.vercruysse.lemmyapi.dto.SortType 9 | import kotlinx.coroutines.runBlocking 10 | import org.junit.Assert.assertEquals 11 | import org.junit.Assert.assertNotNull 12 | import org.junit.Before 13 | import org.junit.Ignore 14 | import org.junit.Test 15 | 16 | /** 17 | * Example local unit test, which will execute on the development machine (host). 18 | * 19 | * See [testing documentation](http://d.android.com/tools/testing). 20 | */ 21 | @Ignore 22 | class ExampleUnitTest { 23 | @Before 24 | fun init_api() { 25 | runBlocking { API.setLemmyInstance(DEFAULT_INSTANCE) } 26 | } 27 | 28 | @Test 29 | fun addition_isCorrect() { 30 | assertEquals(4, 2 + 2) 31 | } 32 | 33 | @Test 34 | fun testGetSite() = 35 | runBlocking { 36 | val api = API.getInstance() 37 | val out = api.getSite().getOrThrow() 38 | 39 | assertEquals("Lemmy", out.site_view.site.name) 40 | } 41 | 42 | @Test 43 | fun testGetPosts() = 44 | runBlocking { 45 | val api = API.getInstance() 46 | val form = 47 | GetPosts( 48 | ListingType.All, 49 | SortType.Active, 50 | ) 51 | val out = api.getPosts(form).getOrThrow() 52 | assertNotNull(out.posts) 53 | } 54 | 55 | @Test 56 | fun testGetPost() = 57 | runBlocking { 58 | val api = API.getInstance() 59 | val form = 60 | GetPost( 61 | id = 139549, 62 | ) 63 | val out = api.getPost(form).getOrThrow() 64 | assertNotNull(out) 65 | } 66 | 67 | /* 68 | @Test 69 | fun testLogin() = runBlocking { 70 | val api = API.getInstance() 71 | val form = Login(username_or_email = "tester12345", password = "tester12345") 72 | val out = api.login(form).body()!! 73 | assertNotNull(out.jwt) 74 | } 75 | */ 76 | } 77 | -------------------------------------------------------------------------------- /app/src/main/java/com/jerboa/state/VideoAppState.kt: -------------------------------------------------------------------------------- 1 | package com.jerboa.state 2 | 3 | import android.content.Context 4 | import androidx.compose.runtime.Stable 5 | import androidx.compose.runtime.mutableStateOf 6 | import androidx.media3.common.Player 7 | import androidx.media3.exoplayer.ExoPlayer 8 | import androidx.media3.ui.PlayerView 9 | 10 | @Stable 11 | class VideoAppState { 12 | private var exoPlayer: ExoPlayer? = null 13 | 14 | val activeEmbedId = mutableStateOf(null) 15 | 16 | // Map to store the distance from top for each video 17 | private val videoDistances = mutableMapOf() 18 | 19 | val isVideoPlayerMuted = mutableStateOf(true) 20 | val isEmbedVideoMuted = mutableStateOf(true) 21 | 22 | @Synchronized 23 | fun getOrCreateExoPlayer(context: Context): ExoPlayer { 24 | if (exoPlayer == null) { 25 | exoPlayer = ExoPlayer.Builder(context).build().apply { 26 | repeatMode = Player.REPEAT_MODE_ONE 27 | volume = if (isEmbedVideoMuted.value) 0f else 1f 28 | } 29 | } 30 | return exoPlayer!! 31 | } 32 | 33 | fun releaseExoPlayer() { 34 | exoPlayer?.release() 35 | exoPlayer = null 36 | } 37 | 38 | fun toggleVideoMute() { 39 | isEmbedVideoMuted.value = !isEmbedVideoMuted.value 40 | exoPlayer?.volume = if (isEmbedVideoMuted.value) 0f else 1f 41 | } 42 | 43 | fun updateVideoDistance( 44 | id: Long, 45 | distance: Float, 46 | isVisible: Boolean, 47 | ) { 48 | if (isVisible) { 49 | videoDistances[id] = distance 50 | 51 | val closestVideo = videoDistances.minByOrNull { it.value } 52 | 53 | if (closestVideo != null) { 54 | activeEmbedId.value = closestVideo.key 55 | } 56 | } else { 57 | videoDistances.remove(id) 58 | 59 | // If the active video is no longer visible, find a new active video 60 | if (activeEmbedId.value == id) { 61 | val closestVideo = videoDistances.minByOrNull { it.value } 62 | activeEmbedId.value = closestVideo?.key 63 | 64 | if (closestVideo == null) { 65 | exoPlayer?.stop() 66 | } 67 | } 68 | } 69 | } 70 | 71 | fun removeVideoDistance(id: Long) { 72 | videoDistances.remove(id) 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /app/schemas/com.jerboa.db.AppDB/2.json: -------------------------------------------------------------------------------- 1 | { 2 | "formatVersion": 1, 3 | "database": { 4 | "version": 2, 5 | "identityHash": "8aaba57eb022b3788c0a405033feb0da", 6 | "entities": [ 7 | { 8 | "tableName": "Account", 9 | "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `current` INTEGER NOT NULL, `instance` TEXT NOT NULL, `name` TEXT NOT NULL, `jwt` TEXT NOT NULL, `default_listing_type` INTEGER NOT NULL DEFAULT 0, `default_sort_type` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`id`))", 10 | "fields": [ 11 | { 12 | "fieldPath": "id", 13 | "columnName": "id", 14 | "affinity": "INTEGER", 15 | "notNull": true 16 | }, 17 | { 18 | "fieldPath": "current", 19 | "columnName": "current", 20 | "affinity": "INTEGER", 21 | "notNull": true 22 | }, 23 | { 24 | "fieldPath": "instance", 25 | "columnName": "instance", 26 | "affinity": "TEXT", 27 | "notNull": true 28 | }, 29 | { 30 | "fieldPath": "name", 31 | "columnName": "name", 32 | "affinity": "TEXT", 33 | "notNull": true 34 | }, 35 | { 36 | "fieldPath": "jwt", 37 | "columnName": "jwt", 38 | "affinity": "TEXT", 39 | "notNull": true 40 | }, 41 | { 42 | "fieldPath": "defaultListingType", 43 | "columnName": "default_listing_type", 44 | "affinity": "INTEGER", 45 | "notNull": true, 46 | "defaultValue": "0" 47 | }, 48 | { 49 | "fieldPath": "defaultSortType", 50 | "columnName": "default_sort_type", 51 | "affinity": "INTEGER", 52 | "notNull": true, 53 | "defaultValue": "0" 54 | } 55 | ], 56 | "primaryKey": { 57 | "columnNames": [ 58 | "id" 59 | ], 60 | "autoGenerate": false 61 | }, 62 | "indices": [], 63 | "foreignKeys": [] 64 | } 65 | ], 66 | "views": [], 67 | "setupQueries": [ 68 | "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", 69 | "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '8aaba57eb022b3788c0a405033feb0da')" 70 | ] 71 | } 72 | } -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_launcher_mono.xml: -------------------------------------------------------------------------------- 1 | 6 | 10 | 14 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /benchmarks/src/main/java/com/jerboa/baselineprofile/BaselineProfileGenerator.kt: -------------------------------------------------------------------------------- 1 | package com.jerboa.baselineprofile 2 | 3 | import androidx.benchmark.macro.junit4.BaselineProfileRule 4 | import androidx.test.ext.junit.runners.AndroidJUnit4 5 | import androidx.test.filters.LargeTest 6 | import androidx.test.platform.app.InstrumentationRegistry 7 | import com.jerboa.actions.closeChangeLogIfOpen 8 | import com.jerboa.actions.doTypicalUserJourney 9 | import com.jerboa.actions.scrollThroughPostsShort 10 | import com.jerboa.actions.waitUntilLoadingDone 11 | import com.jerboa.actions.waitUntilPostsActuallyVisible 12 | import org.junit.Rule 13 | import org.junit.Test 14 | import org.junit.runner.RunWith 15 | 16 | /** 17 | * This test class generates a basic startup baseline profile for the target package. 18 | * 19 | * We recommend you start with this but add important user flows to the profile to improve their performance. 20 | * Refer to the [baseline profile documentation](https://d.android.com/topic/performance/baselineprofiles) 21 | * for more information. 22 | * 23 | * You can run the generator with the Generate Baseline Profile run configuration, 24 | * or directly with `generateBaselineProfile` Gradle task: 25 | * ``` 26 | * ./gradlew :app:generateBaselineProfile -Pandroid.testInstrumentationRunnerArguments.androidx.benchmark.enabledRules=BaselineProfile 27 | * ``` 28 | * The run configuration runs the Gradle task and applies filtering to run only the generators. 29 | * 30 | * Check [documentation](https://d.android.com/topic/performance/benchmarking/macrobenchmark-instrumentation-args) 31 | * for more information about available instrumentation arguments. 32 | * 33 | * After you run the generator, you can verify the improvements running the [StartupBenchmarks] benchmark. 34 | **/ 35 | @RunWith(AndroidJUnit4::class) 36 | @LargeTest 37 | class BaselineProfileGenerator { 38 | @get:Rule 39 | val rule = BaselineProfileRule() 40 | 41 | @Test 42 | fun generate() { 43 | rule.collect( 44 | packageName = InstrumentationRegistry.getArguments().getString("targetAppId") 45 | ?: throw Exception("targetAppId not passed as instrumentation runner arg"), 46 | ) { 47 | pressHome() 48 | startActivityAndWait() 49 | closeChangeLogIfOpen() 50 | waitUntilLoadingDone() 51 | waitUntilPostsActuallyVisible() 52 | scrollThroughPostsShort() 53 | doTypicalUserJourney(3) 54 | } 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /app/src/main/java/com/jerboa/feat/ModActions.kt: -------------------------------------------------------------------------------- 1 | package com.jerboa.feat 2 | 3 | import android.content.Context 4 | import com.jerboa.MainActivity 5 | import com.jerboa.db.entity.Account 6 | import com.jerboa.findActivity 7 | import it.vercruysse.lemmyapi.datatypes.CommunityId 8 | import it.vercruysse.lemmyapi.datatypes.PersonId 9 | import it.vercruysse.lemmyapi.datatypes.PersonView 10 | import java.time.Instant 11 | import java.time.temporal.ChronoUnit 12 | 13 | /** 14 | * Determines whether someone can moderate an item. Uses a hierarchy of admins then mods. 15 | */ 16 | fun canMod( 17 | creatorId: PersonId, 18 | admins: List?, 19 | moderators: List?, 20 | myId: PersonId, 21 | onSelf: Boolean = false, 22 | ): Boolean { 23 | // You can do moderator actions only on the mods added after you. 24 | val adminIds = admins?.map { a -> a.person.id }.orEmpty() 25 | val modIds = moderators.orEmpty() 26 | 27 | val adminsThenMods = adminIds.toMutableList() 28 | adminsThenMods.addAll(modIds) 29 | 30 | val myIndex = adminsThenMods.indexOf(myId) 31 | return if (myIndex == -1) { 32 | false 33 | } else { 34 | // onSelf +1 on mod actions not for yourself, IE ban, remove, etc 35 | val subList = adminsThenMods.subList(0, myIndex.plus(if (onSelf) 0 else 1)) 36 | 37 | !subList.contains(creatorId) 38 | } 39 | } 40 | 41 | fun futureDaysToUnixTime(days: Long?): Long? = 42 | days?.let { 43 | Instant.now().plus(it, ChronoUnit.DAYS).epochSecond 44 | } 45 | 46 | fun amMod( 47 | moderators: List?, 48 | myId: PersonId, 49 | ): Boolean = moderators?.contains(myId) ?: false 50 | 51 | /** 52 | * In screens with posts from different communities we don't have access to moderators of those communities 53 | * So that means that non admin mods can't moderate those posts from that screen 54 | * 55 | * So this is QoL were we simulate the mods of the community 56 | * It is not completely accurate as it doesn't take into account the hierarchy of mods 57 | */ 58 | fun simulateModerators( 59 | ctx: Context, 60 | account: Account, 61 | forCommunity: CommunityId, 62 | ): List { 63 | if (account.isMod) { 64 | val siteVM = (ctx.findActivity() as MainActivity).siteViewModel 65 | val canModerate = siteVM.moderatedCommunities().orEmpty().contains(forCommunity) 66 | if (canModerate) { 67 | return listOf(account.id) 68 | } 69 | } 70 | return emptyList() 71 | } 72 | -------------------------------------------------------------------------------- /app/src/test/java/com/jerboa/util/markwon/ScriptRewriteSupportPluginTest.kt: -------------------------------------------------------------------------------- 1 | package com.jerboa.util.markwon 2 | 3 | import junitparams.JUnitParamsRunner 4 | import junitparams.Parameters 5 | import org.junit.Assert 6 | import org.junit.Test 7 | import org.junit.runner.RunWith 8 | 9 | @RunWith(JUnitParamsRunner::class) 10 | class ScriptRewriteSupportPluginTest { 11 | @Test 12 | @Parameters( 13 | method = "successCases", 14 | ) 15 | fun `should rewrite lemmy script to markwon script`( 16 | input: String, 17 | expected: String, 18 | ) { 19 | val result = ScriptRewriteSupportPlugin.rewriteLemmyScriptToMarkwonScript(input) 20 | Assert.assertEquals(expected, result) 21 | } 22 | 23 | fun successCases() = 24 | listOf( 25 | listOf("^2^", "2"), 26 | listOf("~2~", "2"), 27 | listOf("~2~ ~2~", "2 2"), 28 | listOf("^2^ ^2^", "2 2"), 29 | listOf("^^", "^^"), 30 | listOf("^\n^", "^\n^"), 31 | // Due to a parse limitation, the following case isn't fully supported 32 | // The negative lookbehind matches with consumed tokens :/ 33 | listOf("~2~~2~", "2~2~"), 34 | listOf("~2~\n~2~", "2\n2"), 35 | listOf("~2~\n~2~", "2\n2"), 36 | listOf("~ blah blah", "~ blah blah"), 37 | listOf("", ""), 38 | // Strikethrough syntax 39 | listOf("~~text~~", "~~text~~"), 40 | // Intended to fail, else it will increase the complexity of the regex by a huge margin 41 | listOf("~~text~", "~~text~"), 42 | listOf("~text~~", "text~"), 43 | listOf( 44 | "Tesla model X (range ~ 260kms) first, now a model Y LR (range ~ 480kms)", 45 | "Tesla model X (range ~ 260kms) first, now a model Y LR (range ~ 480kms)", 46 | ), 47 | listOf("~ 5 ~ 6 ~", "~ 5 ~ 6 ~"), 48 | listOf("^ ^", "^ ^"), 49 | listOf("^", "^"), 50 | listOf("~", "~"), 51 | listOf("~~", "~~"), 52 | listOf("~~~", "~~~"), 53 | listOf("^ 99 ^", "^ 99 ^"), 54 | listOf("^ 99^", "^ 99^"), 55 | listOf("^99 ^", "^99 ^"), 56 | listOf("~ 99 ~", "~ 99 ~"), 57 | listOf("~ 99~", "~ 99~"), 58 | listOf("~99 ~", "~99 ~"), 59 | ) 60 | } 61 | -------------------------------------------------------------------------------- /app/src/main/java/com/jerboa/ui/components/settings/account/AccountSettingsScreen.kt: -------------------------------------------------------------------------------- 1 | 2 | package com.jerboa.ui.components.settings.account 3 | 4 | import android.util.Log 5 | import androidx.compose.material3.Scaffold 6 | import androidx.compose.material3.SnackbarHostState 7 | import androidx.compose.runtime.Composable 8 | import androidx.compose.runtime.remember 9 | import androidx.compose.ui.platform.LocalContext 10 | import androidx.compose.ui.res.stringResource 11 | import com.jerboa.R 12 | import com.jerboa.api.ApiState 13 | import com.jerboa.model.AccountSettingsViewModel 14 | import com.jerboa.model.AccountViewModel 15 | import com.jerboa.model.SiteViewModel 16 | import com.jerboa.ui.components.common.ActionTopBar 17 | import com.jerboa.ui.components.common.JerboaSnackbarHost 18 | import com.jerboa.ui.components.common.getCurrentAccount 19 | 20 | @Composable 21 | fun AccountSettingsScreen( 22 | accountSettingsViewModel: AccountSettingsViewModel, 23 | accountViewModel: AccountViewModel, 24 | siteViewModel: SiteViewModel, 25 | onBack: () -> Unit, 26 | ) { 27 | Log.d("jerboa", "Got to settings screen") 28 | val ctx = LocalContext.current 29 | val account = getCurrentAccount(accountViewModel = accountViewModel) 30 | val snackbarHostState = remember { SnackbarHostState() } 31 | 32 | val loading = 33 | when (accountSettingsViewModel.saveUserSettingsRes) { 34 | ApiState.Loading -> true 35 | else -> false 36 | } 37 | 38 | Scaffold( 39 | snackbarHost = { JerboaSnackbarHost(snackbarHostState) }, 40 | topBar = { 41 | ActionTopBar( 42 | onBackClick = onBack, 43 | onActionClick = { 44 | accountSettingsViewModel.saveSettings( 45 | siteViewModel.saveUserSettings, 46 | siteViewModel = siteViewModel, 47 | account = account, 48 | ctx, 49 | onSuccess = onBack, 50 | ) 51 | }, 52 | loading = loading, 53 | title = stringResource(R.string.account_settings_screen_account_settings), 54 | actionText = R.string.account_settings_save_settings, 55 | ) 56 | }, 57 | content = { padding -> 58 | SettingsForm( 59 | siteViewModel = siteViewModel, 60 | account = account, 61 | padding = padding, 62 | ) 63 | }, 64 | ) 65 | } 66 | -------------------------------------------------------------------------------- /app/src/main/java/com/jerboa/feed/FeedController.kt: -------------------------------------------------------------------------------- 1 | package com.jerboa.feed 2 | 3 | import android.util.Log 4 | import androidx.compose.runtime.mutableStateListOf 5 | 6 | open class FeedController { 7 | protected val items = mutableStateListOf() 8 | 9 | val feed: List = items 10 | 11 | open fun updateAll( 12 | selector: (List) -> List, 13 | transformer: (T) -> T, 14 | ) { 15 | selector(items).forEach { 16 | safeUpdate(it, transformer) 17 | } 18 | } 19 | 20 | open fun safeUpdate( 21 | index: Int, 22 | transformer: (T) -> T, 23 | ) { 24 | if (!isValidIndex(index)) { 25 | Log.d("FeedController", "OoB item not updated $index") 26 | return 27 | } 28 | 29 | safeUpdate(index, transformer(items[index])) 30 | } 31 | 32 | open fun safeUpdate( 33 | selector: (List) -> Int, 34 | transformer: (T) -> T, 35 | ) { 36 | safeUpdate(selector(items), transformer) 37 | } 38 | 39 | /** 40 | * Update the item at the given index with the new item. 41 | * 42 | * If given -1 or an index that is out of bounds, the update will not be performed. 43 | * It assumes that the item couldn't be found because the list has changed. 44 | * Example: a network request to update an item succeeded after the list has changed. 45 | * So, we ignore it 46 | */ 47 | open fun safeUpdate( 48 | index: Int, 49 | new: T, 50 | ) { 51 | if (isValidIndex(index)) { 52 | items[index] = new 53 | } else { 54 | Log.d("FeedController", "OoB item not updated $new") 55 | } 56 | } 57 | 58 | open fun init(newItems: List) { 59 | clear() 60 | addAll(newItems) 61 | } 62 | 63 | open fun get(index: Int): T? = items.getOrNull(index) 64 | 65 | open fun add(item: T) = items.add(item) 66 | 67 | open fun remove(item: T) = items.remove(item) 68 | 69 | open fun removeAt(index: Int) { 70 | if (isValidIndex(index)) { 71 | items.removeAt(index) 72 | } 73 | } 74 | 75 | open fun clear() = items.clear() 76 | 77 | open fun addAll(newItems: List) { 78 | items.addAll(newItems) 79 | } 80 | 81 | protected inline fun Iterable.indexesOf(predicate: (E) -> Boolean) = 82 | mapIndexedNotNull { index, elem -> 83 | index.takeIf { 84 | predicate(elem) 85 | } 86 | } 87 | 88 | private fun isValidIndex(index: Int) = index >= 0 && index < items.size 89 | } 90 | -------------------------------------------------------------------------------- /app/src/main/java/com/jerboa/model/PostRemoveViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.jerboa.model 2 | 3 | import android.content.Context 4 | import android.content.res.Resources 5 | import android.util.Log 6 | import android.widget.Toast 7 | import androidx.compose.runtime.getValue 8 | import androidx.compose.runtime.mutableStateOf 9 | import androidx.compose.runtime.setValue 10 | import androidx.compose.ui.focus.FocusManager 11 | import androidx.lifecycle.ViewModel 12 | import androidx.lifecycle.viewModelScope 13 | import com.jerboa.R 14 | import com.jerboa.api.API 15 | import com.jerboa.api.ApiState 16 | import com.jerboa.api.toApiState 17 | import com.jerboa.ui.components.common.apiErrorToast 18 | import it.vercruysse.lemmyapi.datatypes.PostId 19 | import it.vercruysse.lemmyapi.datatypes.PostResponse 20 | import it.vercruysse.lemmyapi.datatypes.PostView 21 | import it.vercruysse.lemmyapi.datatypes.RemovePost 22 | import kotlinx.coroutines.launch 23 | 24 | class PostRemoveViewModel : ViewModel() { 25 | var postRemoveRes: ApiState by mutableStateOf(ApiState.Empty) 26 | private set 27 | 28 | fun removeOrRestorePost( 29 | postId: PostId, 30 | removed: Boolean, 31 | reason: String, 32 | ctx: Context, 33 | resources: Resources, 34 | focusManager: FocusManager, 35 | onSuccess: (PostView) -> Unit, 36 | ) { 37 | viewModelScope.launch { 38 | val form = 39 | RemovePost( 40 | post_id = postId, 41 | removed = removed, 42 | reason = reason, 43 | ) 44 | 45 | postRemoveRes = ApiState.Loading 46 | postRemoveRes = API.getInstance().removePost(form).toApiState() 47 | 48 | when (val res = postRemoveRes) { 49 | is ApiState.Failure -> { 50 | Log.d("removePost", "failed", res.msg) 51 | apiErrorToast(msg = res.msg, ctx = ctx) 52 | } 53 | 54 | is ApiState.Success -> { 55 | val message = 56 | if (removed) { 57 | resources.getString(R.string.post_removed) 58 | } else { 59 | resources.getString(R.string.post_restored) 60 | } 61 | val postView = res.data.post_view 62 | Toast.makeText(ctx, message, Toast.LENGTH_SHORT).show() 63 | 64 | focusManager.clearFocus() 65 | onSuccess(postView) 66 | } 67 | 68 | else -> {} 69 | } 70 | } 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /benchmarks/src/main/java/com/jerboa/benchmarks/ScrollCommentsBenchmarks.kt: -------------------------------------------------------------------------------- 1 | package com.jerboa.benchmarks 2 | 3 | import androidx.benchmark.macro.BaselineProfileMode 4 | import androidx.benchmark.macro.CompilationMode 5 | import androidx.benchmark.macro.FrameTimingMetric 6 | import androidx.benchmark.macro.StartupMode 7 | import androidx.benchmark.macro.junit4.MacrobenchmarkRule 8 | import androidx.test.ext.junit.runners.AndroidJUnit4 9 | import androidx.test.filters.LargeTest 10 | import androidx.test.platform.app.InstrumentationRegistry 11 | import com.jerboa.actions.clickMostComments 12 | import com.jerboa.actions.closeChangeLogIfOpen 13 | import com.jerboa.actions.closePost 14 | import com.jerboa.actions.openPost 15 | import com.jerboa.actions.openSortOptions 16 | import com.jerboa.actions.scrollThroughComments 17 | import com.jerboa.actions.scrollThroughPostsOnce 18 | import com.jerboa.actions.waitUntilLoadingDone 19 | import com.jerboa.actions.waitUntilPostsActuallyVisible 20 | import org.junit.Rule 21 | import org.junit.Test 22 | import org.junit.runner.RunWith 23 | 24 | @RunWith(AndroidJUnit4::class) 25 | @LargeTest 26 | class ScrollCommentsBenchmarks { 27 | @get:Rule 28 | val rule = MacrobenchmarkRule() 29 | 30 | @Test 31 | fun scrollCommentsCompilationNone() = benchmark(CompilationMode.None()) 32 | 33 | @Test 34 | fun scrollCommentsCompilationBaselineProfiles() = benchmark(CompilationMode.Partial(BaselineProfileMode.Require)) 35 | 36 | private fun benchmark(compilationMode: CompilationMode) { 37 | rule.measureRepeated( 38 | packageName = InstrumentationRegistry.getArguments().getString("targetAppId") 39 | ?: throw Exception("targetAppId not passed as instrumentation runner arg"), 40 | metrics = listOf(FrameTimingMetric()), 41 | compilationMode = compilationMode, 42 | startupMode = StartupMode.WARM, 43 | iterations = 5, 44 | setupBlock = { 45 | pressHome() 46 | startActivityAndWait() 47 | closeChangeLogIfOpen() 48 | waitUntilLoadingDone() 49 | waitUntilPostsActuallyVisible() 50 | openSortOptions() 51 | clickMostComments() 52 | waitUntilPostsActuallyVisible() 53 | scrollThroughPostsOnce() 54 | while (!openPost()) { // Could fail at loading a post with its comments 55 | closePost() 56 | scrollThroughPostsOnce() 57 | } 58 | }, 59 | measureBlock = { 60 | scrollThroughComments() 61 | }, 62 | ) 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /app/src/main/java/com/jerboa/DefaultInstances.kt: -------------------------------------------------------------------------------- 1 | /*********************************************** 2 | * WARNING: AUTO-GENERATED FILE * 3 | ***********************************************/ 4 | 5 | // The following file is automatically generated by the script "update_instance_gradle.kts". 6 | // Caution: Manual modifications to this file may be overwritten without notice. 7 | // It is recommended to make changes to the code section responsible for the generation. 8 | 9 | // Date of Auto-generation: 2024-06-12 10 | 11 | package com.jerboa 12 | 13 | val DEFAULT_LEMMY_INSTANCES = setOf( 14 | "lemmy.world", // 18236 monthly users 15 | "lemm.ee", // 3404 monthly users 16 | "lemmy.ml", // 2493 monthly users 17 | "sh.itjust.works", // 2489 monthly users 18 | "hexbear.net", // 1699 monthly users 19 | "feddit.de", // 1567 monthly users 20 | "lemmy.ca", // 1407 monthly users 21 | "programming.dev", // 1146 monthly users 22 | "lemmy.dbzer0.com", // 1119 monthly users 23 | "discuss.tchncs.de", // 938 monthly users 24 | "lemmy.blahaj.zone", // 914 monthly users 25 | "lemmygrad.ml", // 582 monthly users 26 | "sopuli.xyz", // 572 monthly users 27 | "lemmy.sdf.org", // 548 monthly users 28 | "lemmy.zip", // 513 monthly users 29 | "beehaw.org", // 420 monthly users 30 | "aussie.zone", // 376 monthly users 31 | "feddit.nl", // 371 monthly users 32 | "infosec.pub", // 353 monthly users 33 | "midwest.social", // 345 monthly users 34 | "reddthat.com", // 314 monthly users 35 | "feddit.uk", // 301 monthly users 36 | "slrpnk.net", // 297 monthly users 37 | "lemmy.one", // 278 monthly users 38 | "jlai.lu", // 246 monthly users 39 | "pawb.social", // 229 monthly users 40 | "lemmy.today", // 212 monthly users 41 | "startrek.website", // 211 monthly users 42 | "feddit.it", // 207 monthly users 43 | "ttrpg.network", // 174 monthly users 44 | "mander.xyz", // 164 monthly users 45 | "lemmings.world", // 157 monthly users 46 | "lemdro.id", // 148 monthly users 47 | "lemmy.eco.br", // 147 monthly users 48 | "ani.social", // 133 monthly users 49 | "szmer.info", // 121 monthly users 50 | "lemy.lol", // 118 monthly users 51 | "lemmy.nz", // 117 monthly users 52 | "monero.town", // 115 monthly users 53 | "burggit.moe", // 102 monthly users 54 | "discuss.online", // 99 monthly users 55 | "feddit.dk", // 99 monthly users 56 | "awful.systems", // 94 monthly users 57 | "thelemmy.club", // 76 monthly users 58 | "feddit.nu", // 67 monthly users 59 | "yiffit.net", // 67 monthly users 60 | "leminal.space", // 59 monthly users 61 | "lemmy.wtf", // 53 monthly users 62 | ) 63 | -------------------------------------------------------------------------------- /benchmarks/build.gradle.kts: -------------------------------------------------------------------------------- 1 | @file:Suppress("UnstableApiUsage") 2 | import com.android.build.api.dsl.ManagedVirtualDevice 3 | import org.jetbrains.kotlin.gradle.dsl.JvmTarget 4 | 5 | plugins { 6 | id("com.android.test") 7 | id("org.jetbrains.kotlin.android") 8 | id("androidx.baselineprofile") 9 | } 10 | 11 | kotlin { 12 | compilerOptions { 13 | jvmTarget = JvmTarget.fromTarget("17") 14 | freeCompilerArgs = listOf("-Xjvm-default=all-compatibility", "-opt-in=kotlin.RequiresOptIn") 15 | } 16 | } 17 | 18 | android { 19 | namespace = "com.jerboa.benchmarks" 20 | compileSdk = 36 21 | 22 | compileOptions { 23 | sourceCompatibility = JavaVersion.VERSION_17 24 | targetCompatibility = JavaVersion.VERSION_17 25 | } 26 | 27 | defaultConfig { 28 | testInstrumentationRunnerArguments += mapOf("suppressErrors" to "EMULATOR") 29 | minSdk = 28 30 | targetSdk = 36 31 | 32 | testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" 33 | } 34 | 35 | targetProjectPath = ":app" 36 | 37 | // This code creates the gradle managed device used to generate baseline profiles. 38 | // To use GMD please invoke generation through the command line: 39 | // ./gradlew :app:generateBaselineProfile 40 | testOptions.managedDevices.allDevices { 41 | create("pixel6Api36") { 42 | device = "Pixel 6" 43 | apiLevel = 36 44 | systemImageSource = "google" 45 | } 46 | } 47 | 48 | buildTypes { 49 | register("benchmark") { 50 | isDebuggable = false 51 | signingConfig = signingConfigs.getByName("debug") 52 | matchingFallbacks += listOf("release") 53 | } 54 | } 55 | } 56 | 57 | // This is the configuration block for the Baseline Profile plugin. 58 | // You can specify to run the generators on a managed devices or connected devices. 59 | baselineProfile { 60 | managedDevices += "pixel6Api36" 61 | enableEmulatorDisplay = true 62 | useConnectedDevices = false 63 | } 64 | 65 | dependencies { 66 | implementation("androidx.test.ext:junit:1.3.0") 67 | implementation("androidx.test.espresso:espresso-core:3.7.0") 68 | implementation("androidx.test.uiautomator:uiautomator:2.3.0") 69 | implementation("androidx.benchmark:benchmark-macro-junit4:1.4.1") 70 | } 71 | 72 | androidComponents { 73 | onVariants { v -> 74 | val artifactsLoader = v.artifacts.getBuiltArtifactsLoader() 75 | v.instrumentationRunnerArguments.put( 76 | "targetAppId", 77 | v.testedApks.map { artifactsLoader.load(it)?.applicationId ?: "com.jerboa" } 78 | ) 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /app/src/main/java/com/jerboa/ui/components/videoviewer/OpenGraphParser.kt: -------------------------------------------------------------------------------- 1 | package com.jerboa.ui.components.videoviewer 2 | 3 | import java.util.regex.Pattern 4 | 5 | /** 6 | * Little custom class for parsing basic HTML pages for OGP tags 7 | * 8 | * Couldn't find any existing Java/Kotlin libs and 9 | * didnt want to introduce whole XML parsing libs like Jsoup 10 | * So I ended up with this bit of regex magic. 11 | */ 12 | class OpenGraphParser { 13 | companion object { 14 | private val HEAD_PATTERN = Pattern.compile("(.*?)", Pattern.DOTALL).toRegex() 15 | private val META_TAGS_PATTERN = Pattern.compile("").toRegex() 16 | private val PROPERTY_ATTR_PATTERN = Pattern.compile("property=\"(.*?)\"").toRegex() 17 | private val CONTENT_ATTR_PATTERN = Pattern.compile("content=\"(.*?)\"").toRegex() 18 | 19 | val OG_IMAGE = "og:image" 20 | val OG_TITLE = "og:title" 21 | val OG_DESCRIPTION = "og:description" 22 | val OG_URL = "og:url" 23 | val OG_TYPE = "og:type" 24 | val OG_VIDEO = "og:video" 25 | val OG_VIDEO_WITH = "og:video:width" 26 | val OG_VIDEO_HEIGHT = "og:video:height" 27 | 28 | fun parseHeadFromHtml(html: String): String? { 29 | val headMatcher = HEAD_PATTERN.find(html) ?: return null 30 | return headMatcher.groupValues[1] 31 | } 32 | 33 | fun parseMetaTagsFromHtml(html: String): List = 34 | META_TAGS_PATTERN 35 | .findAll(html) 36 | .map { it.groupValues[1] } 37 | .toList() 38 | 39 | fun findAllPropertyFields(fields: List): List> = 40 | fields 41 | .map { 42 | Pair( 43 | PROPERTY_ATTR_PATTERN.find(it)?.groupValues?.get(1), 44 | CONTENT_ATTR_PATTERN.find(it)?.groupValues?.get(1), 45 | ) 46 | }.filter { it.first != null && it.second != null } 47 | .map { Pair(it.first!!, it.second!!) } 48 | .toList() 49 | 50 | fun findAllPropertiesFromHtml(html: String): List> { 51 | val head = parseHeadFromHtml(html) ?: return emptyList() 52 | return findAllPropertyFields(parseMetaTagsFromHtml(head)) 53 | } 54 | 55 | fun findContent( 56 | tags: List>, 57 | key: String, 58 | ): String? = tags.find { it.first == key }?.second 59 | 60 | fun findContentAsInt( 61 | tags: List>, 62 | key: String, 63 | ): Int? = findContent(tags, key)?.toIntOrNull() 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /app/src/main/java/com/jerboa/model/CommentRemoveViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.jerboa.model 2 | 3 | import android.content.Context 4 | import android.content.res.Resources 5 | import android.util.Log 6 | import android.widget.Toast 7 | import androidx.compose.runtime.getValue 8 | import androidx.compose.runtime.mutableStateOf 9 | import androidx.compose.runtime.setValue 10 | import androidx.compose.ui.focus.FocusManager 11 | import androidx.lifecycle.ViewModel 12 | import androidx.lifecycle.viewModelScope 13 | import com.jerboa.R 14 | import com.jerboa.api.API 15 | import com.jerboa.api.ApiState 16 | import com.jerboa.api.toApiState 17 | import com.jerboa.ui.components.common.apiErrorToast 18 | import it.vercruysse.lemmyapi.datatypes.CommentId 19 | import it.vercruysse.lemmyapi.datatypes.CommentResponse 20 | import it.vercruysse.lemmyapi.datatypes.CommentView 21 | import it.vercruysse.lemmyapi.datatypes.RemoveComment 22 | import kotlinx.coroutines.launch 23 | 24 | class CommentRemoveViewModel : ViewModel() { 25 | var commentRemoveRes: ApiState by mutableStateOf(ApiState.Empty) 26 | private set 27 | 28 | fun removeOrRestoreComment( 29 | commentId: CommentId, 30 | removed: Boolean, 31 | reason: String, 32 | ctx: Context, 33 | resources: Resources, 34 | focusManager: FocusManager, 35 | onSuccess: (CommentView) -> Unit, 36 | ) { 37 | viewModelScope.launch { 38 | val form = 39 | RemoveComment( 40 | comment_id = commentId, 41 | removed = removed, 42 | reason = reason, 43 | ) 44 | 45 | commentRemoveRes = ApiState.Loading 46 | commentRemoveRes = API.getInstance().removeComment(form).toApiState() 47 | 48 | when (val res = commentRemoveRes) { 49 | is ApiState.Failure -> { 50 | Log.d("removeComment", "failed", res.msg) 51 | apiErrorToast(msg = res.msg, ctx = ctx) 52 | } 53 | 54 | is ApiState.Success -> { 55 | val message = 56 | if (removed) { 57 | resources.getString(R.string.comment_removed) 58 | } else { 59 | resources.getString(R.string.comment_restored) 60 | } 61 | val commentView = res.data.comment_view 62 | Toast.makeText(ctx, message, Toast.LENGTH_SHORT).show() 63 | 64 | focusManager.clearFocus() 65 | onSuccess(commentView) 66 | } 67 | 68 | else -> {} 69 | } 70 | } 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /app/src/main/java/com/jerboa/ui/components/ban/BanPerson.kt: -------------------------------------------------------------------------------- 1 | package com.jerboa.ui.components.ban 2 | 3 | import androidx.compose.foundation.layout.Column 4 | import androidx.compose.foundation.layout.PaddingValues 5 | import androidx.compose.foundation.layout.imePadding 6 | import androidx.compose.foundation.layout.padding 7 | import androidx.compose.foundation.rememberScrollState 8 | import androidx.compose.foundation.verticalScroll 9 | import androidx.compose.runtime.Composable 10 | import androidx.compose.ui.Modifier 11 | import androidx.compose.ui.res.stringResource 12 | import androidx.compose.ui.text.input.TextFieldValue 13 | import com.jerboa.R 14 | import com.jerboa.db.entity.Account 15 | import com.jerboa.ui.components.common.CheckboxField 16 | import com.jerboa.ui.components.common.ExpiresField 17 | import com.jerboa.ui.components.common.MarkdownTextField 18 | import com.jerboa.ui.theme.MEDIUM_PADDING 19 | 20 | @Composable 21 | fun BanPersonBody( 22 | isBan: Boolean, 23 | reason: TextFieldValue, 24 | onReasonChange: (TextFieldValue) -> Unit, 25 | expireDays: Long?, 26 | onExpiresChange: (Long?) -> Unit, 27 | permaBan: Boolean, 28 | onPermaBanChange: (Boolean) -> Unit, 29 | removeData: Boolean, 30 | onRemoveDataChange: (Boolean) -> Unit, 31 | isValid: Boolean, 32 | account: Account, 33 | padding: PaddingValues, 34 | ) { 35 | val scrollState = rememberScrollState() 36 | 37 | Column( 38 | modifier = 39 | Modifier 40 | .verticalScroll(scrollState) 41 | .padding( 42 | vertical = padding.calculateTopPadding(), 43 | horizontal = MEDIUM_PADDING, 44 | ).imePadding(), 45 | ) { 46 | MarkdownTextField( 47 | text = reason, 48 | onTextChange = onReasonChange, 49 | account = account, 50 | placeholder = stringResource(R.string.type_your_reason), 51 | ) 52 | 53 | // Only show these fields for a ban, not an unban 54 | if (isBan) { 55 | if (!permaBan) { 56 | ExpiresField( 57 | value = expireDays, 58 | onIntChange = onExpiresChange, 59 | isValid = isValid, 60 | ) 61 | } 62 | 63 | CheckboxField( 64 | label = stringResource(R.string.remove_content), 65 | checked = removeData, 66 | onCheckedChange = onRemoveDataChange, 67 | ) 68 | 69 | CheckboxField( 70 | label = stringResource(R.string.permaban), 71 | checked = permaBan, 72 | onCheckedChange = onPermaBanChange, 73 | ) 74 | } 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /app/src/main/java/com/jerboa/ui/components/login/LoginScreen.kt: -------------------------------------------------------------------------------- 1 | package com.jerboa.ui.components.login 2 | 3 | import android.util.Log 4 | import androidx.compose.foundation.layout.consumeWindowInsets 5 | import androidx.compose.foundation.layout.imePadding 6 | import androidx.compose.foundation.layout.padding 7 | import androidx.compose.material3.ExperimentalMaterial3Api 8 | import androidx.compose.material3.Scaffold 9 | import androidx.compose.material3.SnackbarHost 10 | import androidx.compose.material3.SnackbarHostState 11 | import androidx.compose.runtime.Composable 12 | import androidx.compose.runtime.remember 13 | import androidx.compose.ui.Modifier 14 | import androidx.compose.ui.platform.LocalContext 15 | import androidx.compose.ui.platform.LocalResources 16 | import androidx.compose.ui.res.stringResource 17 | import androidx.lifecycle.viewmodel.compose.viewModel 18 | import com.jerboa.JerboaAppState 19 | import com.jerboa.R 20 | import com.jerboa.model.AccountViewModel 21 | import com.jerboa.model.LoginViewModel 22 | import com.jerboa.model.SiteViewModel 23 | import com.jerboa.ui.components.common.SimpleTopAppBar 24 | 25 | @OptIn(ExperimentalMaterial3Api::class) 26 | @Composable 27 | fun LoginScreen( 28 | appState: JerboaAppState, 29 | accountViewModel: AccountViewModel, 30 | siteViewModel: SiteViewModel, 31 | ) { 32 | Log.d("jerboa", "Got to login screen") 33 | 34 | val snackbarHostState = remember { SnackbarHostState() } 35 | val ctx = LocalContext.current 36 | val resources = LocalResources.current 37 | 38 | val loginViewModel: LoginViewModel = viewModel() 39 | 40 | Scaffold( 41 | snackbarHost = { SnackbarHost(snackbarHostState) }, 42 | topBar = { 43 | SimpleTopAppBar( 44 | text = stringResource(R.string.login_login), 45 | onClickBack = appState::popBackStack, 46 | ) 47 | }, 48 | content = { padding -> 49 | LoginForm( 50 | loading = loginViewModel.loading, 51 | modifier = 52 | Modifier 53 | .padding(padding) 54 | .consumeWindowInsets(padding) 55 | .imePadding(), 56 | onClickLogin = { form, instance -> 57 | loginViewModel.login( 58 | form = form, 59 | instance = instance.trim(), 60 | ctx = ctx, 61 | resources = resources, 62 | accountViewModel = accountViewModel, 63 | siteViewModel = siteViewModel, 64 | onGoHome = appState::toHome, 65 | ) 66 | }, 67 | ) 68 | }, 69 | ) 70 | } 71 | -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/changelogs/78.txt: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | ## What's Changed in 0.0.78 6 | 7 | - Add "Copy Image" action to Image context menu by @MV-GH in [#1840](https://github.com/LemmyNet/jerboa/pull/1840) 8 | - Fix deprecation zoomable constructor by @MV-GH in [#1841](https://github.com/LemmyNet/jerboa/pull/1841) 9 | - Adding weblate translations url. by @dessalines in [#1830](https://github.com/LemmyNet/jerboa/pull/1830) 10 | - Add ko-fi by @dessalines in [#1826](https://github.com/LemmyNet/jerboa/pull/1826) 11 | - Fixes #1755 Keyboard still open after creating comment by @MV-GH in [#1825](https://github.com/LemmyNet/jerboa/pull/1825) 12 | - Fixes #1748 Extra padding above IME keyboard by @MV-GH in [#1824](https://github.com/LemmyNet/jerboa/pull/1824) 13 | - Add Android 15 SDK support, Configure Android lint, deprecation fixes by @MV-GH in [#1823](https://github.com/LemmyNet/jerboa/pull/1823) 14 | - Add Lemmy Donation Dialog by @iByteABit256 in [#1813](https://github.com/LemmyNet/jerboa/pull/1813) 15 | - Add Image Proxy endpoint support to Share/Download actions by @MV-GH in [#1814](https://github.com/LemmyNet/jerboa/pull/1814) 16 | - Fixes: Webp images using image_proxy can't be saved in Android 10+ by @MV-GH in [#1801](https://github.com/LemmyNet/jerboa/pull/1801) 17 | - Fix rare crash on switching account #1757 by @MV-GH in [#1758](https://github.com/LemmyNet/jerboa/pull/1758) 18 | - Fixing bug with torrent magnet link posts. by @dessalines in [#1724](https://github.com/LemmyNet/jerboa/pull/1724) 19 | - Adding an image upload button for custom thumbnails. by @dessalines in [#1725](https://github.com/LemmyNet/jerboa/pull/1725) 20 | - Create strings.xml (zh) by @BingoKingo in [#1722](https://github.com/LemmyNet/jerboa/pull/1722) 21 | - Redesign for blocks screen by @rodrigo-fm in [#1718](https://github.com/LemmyNet/jerboa/pull/1718) 22 | - Bump LemmyApi to Support Lemmy 0.19.7 Features by @MV-GH in [#1719](https://github.com/LemmyNet/jerboa/pull/1719) 23 | - Fixing signing config. by @dessalines in [#1708](https://github.com/LemmyNet/jerboa/pull/1708) 24 | - Update Norwegian Nynorsk translation by @huftis in [#1695](https://github.com/LemmyNet/jerboa/pull/1695) 25 | - Adding the ability to export / import the database. by @dessalines in [#1685](https://github.com/LemmyNet/jerboa/pull/1685) 26 | - Running renovate every weekend. by @dessalines in [#1686](https://github.com/LemmyNet/jerboa/pull/1686) 27 | - Updating git cliff. by @dessalines in [#1682](https://github.com/LemmyNet/jerboa/pull/1682) 28 | 29 | ## New Contributors 30 | 31 | - @BingoKingo made their first contribution in [#1722](https://github.com/LemmyNet/jerboa/pull/1722) 32 | 33 | **Full Changelog**: https://github.com/LemmyNet/jerboa/compare/0.0.77...0.0.78 34 | 35 | 36 | -------------------------------------------------------------------------------- /app/src/main/java/com/jerboa/model/AccountSettingsViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.jerboa.model 2 | 3 | import android.content.Context 4 | import androidx.compose.runtime.Stable 5 | import androidx.compose.runtime.getValue 6 | import androidx.compose.runtime.mutableStateOf 7 | import androidx.compose.runtime.setValue 8 | import androidx.lifecycle.ViewModel 9 | import androidx.lifecycle.viewModelScope 10 | import androidx.lifecycle.viewmodel.initializer 11 | import androidx.lifecycle.viewmodel.viewModelFactory 12 | import com.jerboa.api.API 13 | import com.jerboa.api.ApiState 14 | import com.jerboa.api.toApiState 15 | import com.jerboa.db.entity.Account 16 | import com.jerboa.db.repository.AccountRepository 17 | import com.jerboa.jerboaApplication 18 | import com.jerboa.ui.components.common.apiErrorToast 19 | import it.vercruysse.lemmyapi.datatypes.SaveUserSettings 20 | import kotlinx.coroutines.launch 21 | 22 | @Stable 23 | class AccountSettingsViewModel( 24 | private val accountRepository: AccountRepository, 25 | ) : ViewModel() { 26 | var saveUserSettingsRes: ApiState by mutableStateOf(ApiState.Empty) 27 | private set 28 | 29 | fun saveSettings( 30 | form: SaveUserSettings, 31 | siteViewModel: SiteViewModel, 32 | account: Account, 33 | ctx: Context, 34 | onSuccess: () -> Unit, 35 | ) { 36 | viewModelScope.launch { 37 | saveUserSettingsRes = ApiState.Loading 38 | saveUserSettingsRes = API.getInstance().saveUserSettings(form).toApiState() 39 | 40 | when (val res = saveUserSettingsRes) { 41 | is ApiState.Success -> { 42 | siteViewModel.getSite() 43 | 44 | maybeUpdateAccountSettings(account, form) 45 | onSuccess() 46 | } 47 | 48 | is ApiState.Failure -> { 49 | apiErrorToast(ctx, res.msg) 50 | } 51 | 52 | else -> {} 53 | } 54 | } 55 | } 56 | 57 | private suspend fun maybeUpdateAccountSettings( 58 | account: Account, 59 | form: SaveUserSettings, 60 | ): Account { 61 | val newAccount = 62 | account.copy( 63 | defaultListingType = form.default_listing_type?.ordinal ?: account.defaultListingType, 64 | defaultSortType = form.default_sort_type?.ordinal ?: account.defaultSortType, 65 | ) 66 | if (newAccount != account) { 67 | accountRepository.update(newAccount) 68 | } 69 | return newAccount 70 | } 71 | } 72 | 73 | object AccountSettingsViewModelFactory { 74 | val Factory = 75 | viewModelFactory { 76 | initializer { 77 | AccountSettingsViewModel(jerboaApplication().container.accountRepository) 78 | } 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /app/src/test/java/com/jerboa/feed/UniqueFeedControllerTest.kt: -------------------------------------------------------------------------------- 1 | package com.jerboa.feed 2 | 3 | import it.vercruysse.lemmyapi.Identity 4 | import org.junit.Assert.* 5 | import org.junit.Test 6 | 7 | class UniqueFeedControllerTest { 8 | private data class PostView( 9 | override val id: Long, 10 | ) : Identity 11 | 12 | @Test 13 | fun `Should not add duplicate posts`() { 14 | val controller = UniqueFeedController() 15 | controller.add(PostView(1)) 16 | assertEquals(1, controller.feed.size) 17 | controller.add(PostView(1)) 18 | assertEquals(1, controller.feed.size) 19 | controller.add(PostView(2)) 20 | assertEquals(2, controller.feed.size) 21 | } 22 | 23 | @Test 24 | fun `Should remove post`() { 25 | val controller = UniqueFeedController() 26 | controller.add(PostView(1)) 27 | assertEquals(1, controller.feed.size) 28 | controller.remove(PostView(1)) 29 | assertEquals(0, controller.feed.size) 30 | } 31 | 32 | @Test 33 | fun `Post removal should clear id`() { 34 | val controller = UniqueFeedController() 35 | controller.add(PostView(1)) 36 | assertEquals(1, controller.feed.size) 37 | controller.remove(PostView(1)) 38 | assertTrue(controller.feed.isEmpty()) 39 | controller.add(PostView(1)) 40 | assertEquals(1, controller.feed.size) 41 | } 42 | 43 | @Test 44 | fun `Should clear all posts`() { 45 | val controller = UniqueFeedController() 46 | controller.add(PostView(1)) 47 | controller.add(PostView(2)) 48 | assertEquals(2, controller.feed.size) 49 | controller.clear() 50 | assertTrue(controller.feed.isEmpty()) 51 | } 52 | 53 | @Test 54 | fun `Clear should clear ids`() { 55 | val controller = UniqueFeedController() 56 | controller.add(PostView(1)) 57 | controller.add(PostView(2)) 58 | assertEquals(2, controller.feed.size) 59 | controller.clear() 60 | assertTrue(controller.feed.isEmpty()) 61 | controller.add(PostView(1)) 62 | controller.add(PostView(2)) 63 | assertEquals(2, controller.feed.size) 64 | } 65 | 66 | @Test 67 | fun `Add all should not add duplicates`() { 68 | val controller = UniqueFeedController() 69 | controller.addAll(listOf(PostView(1), PostView(2), PostView(1))) 70 | assertEquals(2, controller.feed.size) 71 | } 72 | 73 | @Test 74 | fun `Init should clear ids`() { 75 | val controller = UniqueFeedController() 76 | controller.add(PostView(1)) 77 | controller.add(PostView(2)) 78 | assertEquals(2, controller.feed.size) 79 | controller.init(listOf(PostView(1), PostView(2))) 80 | assertEquals(2, controller.feed.size) 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /app/src/main/java/com/jerboa/feat/Voting.kt: -------------------------------------------------------------------------------- 1 | package com.jerboa.feat 2 | 3 | import it.vercruysse.lemmyapi.datatypes.LocalUserVoteDisplayMode 4 | 5 | enum class VoteType( 6 | val value: Int, 7 | ) { 8 | Upvote(1), 9 | Downvote(-1), 10 | } 11 | 12 | /** 13 | * This stores live info about votes / scores, in order to update the front end without waiting 14 | * for an API result 15 | */ 16 | data class InstantScores( 17 | val myVote: Int, 18 | val score: Long, 19 | val upvotes: Long, 20 | val downvotes: Long, 21 | ) { 22 | fun update(voteAction: VoteType): InstantScores { 23 | val newVote = newVote(this.myVote, voteAction) 24 | // get original (up/down)votes, add (up/down)vote if (up/down)voted 25 | val upvotes = this.upvotes - (if (this.myVote == 1) 1 else 0) + (if (newVote == 1) 1 else 0) 26 | val downvotes = 27 | this.downvotes - (if (this.myVote == -1) 1 else 0) + (if (newVote == -1) 1 else 0) 28 | 29 | return InstantScores( 30 | myVote = newVote, 31 | upvotes = upvotes, 32 | downvotes = downvotes, 33 | score = upvotes - downvotes, 34 | ) 35 | } 36 | 37 | fun scoreOrPctStr(voteDisplayMode: LocalUserVoteDisplayMode): String? = 38 | scoreOrPctStr( 39 | score = score, 40 | upvotes = upvotes, 41 | downvotes = downvotes, 42 | voteDisplayMode = voteDisplayMode, 43 | ) 44 | } 45 | 46 | // Set myVote to given action unless it was already set to that action, in which case we reset to 0 47 | fun newVote( 48 | oldVote: Int, 49 | voteAction: VoteType, 50 | ): Int = if (voteAction.value == oldVote) 0 else voteAction.value 51 | 52 | fun upvotePercent( 53 | upvotes: Long, 54 | downvotes: Long, 55 | ): Float = (upvotes.toFloat() / (upvotes + downvotes)) 56 | 57 | fun formatPercent(pct: Float): String = "%.0f".format(pct * 100F) 58 | 59 | private fun scoreOrPctStr( 60 | score: Long, 61 | upvotes: Long, 62 | downvotes: Long, 63 | voteDisplayMode: LocalUserVoteDisplayMode, 64 | ): String? = 65 | if (voteDisplayMode.upvote_percentage) { 66 | formatPercent(upvotePercent(upvotes, downvotes)) 67 | } else if (voteDisplayMode.score || voteDisplayMode.upvotes || voteDisplayMode.downvotes) { 68 | score.toString() 69 | } else { 70 | null 71 | } 72 | 73 | fun LocalUserVoteDisplayMode.Companion.default(score: Boolean? = false) = 74 | LocalUserVoteDisplayMode( 75 | local_user_id = -1, 76 | upvotes = true, 77 | downvotes = true, 78 | score = score ?: false, 79 | upvote_percentage = false, 80 | ) 81 | 82 | fun LocalUserVoteDisplayMode.Companion.allHidden() = 83 | LocalUserVoteDisplayMode( 84 | local_user_id = -1, 85 | upvotes = false, 86 | downvotes = false, 87 | score = false, 88 | upvote_percentage = false, 89 | ) 90 | -------------------------------------------------------------------------------- /app/src/main/java/com/jerboa/util/markwon/MarkwonLemmyLinkPlugin.kt: -------------------------------------------------------------------------------- 1 | package com.jerboa.util.markwon 2 | 3 | import android.text.Spannable 4 | import android.text.SpannableStringBuilder 5 | import android.text.style.URLSpan 6 | import android.text.util.Linkify 7 | import com.jerboa.ui.components.common.lemmyCommunityPattern 8 | import com.jerboa.ui.components.common.lemmyUserPattern 9 | import io.noties.markwon.AbstractMarkwonPlugin 10 | import io.noties.markwon.MarkwonPlugin 11 | import io.noties.markwon.MarkwonVisitor 12 | import io.noties.markwon.SpannableBuilder 13 | import io.noties.markwon.core.CorePlugin 14 | import io.noties.markwon.core.CoreProps 15 | import org.commonmark.node.Link 16 | 17 | /** 18 | * Plugin to turn Lemmy-specific URIs into clickable links. 19 | */ 20 | class MarkwonLemmyLinkPlugin : AbstractMarkwonPlugin() { 21 | override fun configure(registry: MarkwonPlugin.Registry) { 22 | registry.require(CorePlugin::class.java) { it.addOnTextAddedListener(LemmyTextAddedListener()) } 23 | } 24 | 25 | private class LemmyTextAddedListener : CorePlugin.OnTextAddedListener { 26 | override fun onTextAdded( 27 | visitor: MarkwonVisitor, 28 | text: String, 29 | start: Int, 30 | ) { 31 | // we will be using the link that is used by markdown (instead of directly applying URLSpan) 32 | val spanFactory = 33 | visitor.configuration().spansFactory().get( 34 | Link::class.java, 35 | ) ?: return 36 | 37 | // don't re-use builder (thread safety achieved for 38 | // render calls from different threads and ... better performance) 39 | val builder = SpannableStringBuilder(text) 40 | if (addLinks(builder)) { 41 | // target URL span specifically 42 | val spans = builder.getSpans(0, builder.length, URLSpan::class.java) 43 | if (!spans.isNullOrEmpty()) { 44 | val renderProps = visitor.renderProps() 45 | val spannableBuilder = visitor.builder() 46 | for (span in spans) { 47 | CoreProps.LINK_DESTINATION[renderProps] = span.url 48 | SpannableBuilder.setSpans( 49 | spannableBuilder, 50 | spanFactory.getSpans(visitor.configuration(), renderProps), 51 | start + builder.getSpanStart(span), 52 | start + builder.getSpanEnd(span), 53 | ) 54 | } 55 | } 56 | } 57 | } 58 | 59 | fun addLinks(text: Spannable): Boolean { 60 | val communityLinkAdded = Linkify.addLinks(text, lemmyCommunityPattern, null) 61 | val userLinkAdded = Linkify.addLinks(text, lemmyUserPattern, null) 62 | 63 | return communityLinkAdded || userLinkAdded 64 | } 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /app/src/main/java/com/jerboa/ui/components/videoviewer/hosts/SendvidVideoHost.kt: -------------------------------------------------------------------------------- 1 | package com.jerboa.ui.components.videoviewer.hosts 2 | 3 | import com.jerboa.api.API 4 | import com.jerboa.ui.components.videoviewer.EmbeddedData 5 | import com.jerboa.ui.components.videoviewer.OpenGraphParser 6 | import com.jerboa.ui.components.videoviewer.ResourceMissing 7 | import okhttp3.Request 8 | import java.util.regex.Pattern 9 | 10 | /** 11 | * VideoHost implementation for Sendvid.com 12 | * 13 | * Sendvid implements full OG, but it limits the links with 2 hours expiry 14 | * So this custom implementation fetches a new link each time. 15 | * By parsing the OG tags 16 | */ 17 | class SendvidVideoHost : SupportedVideoHost { 18 | companion object { 19 | private val SENDVID_PATTERN = Pattern.compile("(?:https?://)?(?:www\\.)?sendvid\\.com/([a-zA-Z0-9]+)(?:\\?.*)?") 20 | } 21 | 22 | override fun isSupported(url: String): Boolean = SENDVID_PATTERN.matcher(url).find() 23 | 24 | override fun getVideoData(url: String): Result = 25 | runCatching { 26 | val request = Request 27 | .Builder() 28 | .url(url) 29 | .build() 30 | 31 | val response = API.httpClient.newCall(request).execute() 32 | 33 | if (!response.isSuccessful) { 34 | throw ResourceMissing() 35 | } 36 | 37 | val responseBody = response.body?.string() ?: throw IllegalStateException("Empty response from Sendvid") 38 | 39 | val tags = OpenGraphParser.Companion.findAllPropertiesFromHtml(responseBody) 40 | 41 | val title = OpenGraphParser.Companion.findContent(tags, OpenGraphParser.Companion.OG_TITLE) 42 | val thumbnailUrl = OpenGraphParser.Companion.findContent(tags, OpenGraphParser.Companion.OG_IMAGE) 43 | val videoUrl = 44 | OpenGraphParser.Companion.findContent(tags, OpenGraphParser.Companion.OG_VIDEO) 45 | ?: throw IllegalArgumentException("No video source found in Sendvid page") 46 | val videoWidth = 47 | OpenGraphParser.Companion.findContentAsInt(tags, OpenGraphParser.Companion.OG_VIDEO_WITH) 48 | ?: throw IllegalArgumentException("No video width found in Sendvid page") 49 | val videoHeight = 50 | OpenGraphParser.Companion.findContentAsInt(tags, OpenGraphParser.Companion.OG_VIDEO_HEIGHT) 51 | ?: throw IllegalArgumentException("No video height found in Sendvid page") 52 | 53 | EmbeddedData( 54 | videoUrl = videoUrl, 55 | thumbnailUrl = thumbnailUrl, 56 | typeName = getShortTypeName(), 57 | title = title, 58 | height = videoHeight, 59 | width = videoWidth, 60 | aspectRatio = videoWidth / (videoHeight * 1F), 61 | ) 62 | } 63 | 64 | override fun getShortTypeName(): String = "Sendvid" 65 | } 66 | -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/changelogs/79.txt: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | ## What's Changed in 0.0.79 6 | 7 | - Update baseline profiles by @MV-GH in [#1845](https://github.com/LemmyNet/jerboa/pull/1845) 8 | - Fixing changelog script. by @dessalines in [#1844](https://github.com/LemmyNet/jerboa/pull/1844) 9 | - Add "Copy Image" action to Image context menu by @MV-GH in [#1840](https://github.com/LemmyNet/jerboa/pull/1840) 10 | - Fix deprecation zoomable constructor by @MV-GH in [#1841](https://github.com/LemmyNet/jerboa/pull/1841) 11 | - Adding weblate translations url. by @dessalines in [#1830](https://github.com/LemmyNet/jerboa/pull/1830) 12 | - Add ko-fi by @dessalines in [#1826](https://github.com/LemmyNet/jerboa/pull/1826) 13 | - Fixes #1755 Keyboard still open after creating comment by @MV-GH in [#1825](https://github.com/LemmyNet/jerboa/pull/1825) 14 | - Fixes #1748 Extra padding above IME keyboard by @MV-GH in [#1824](https://github.com/LemmyNet/jerboa/pull/1824) 15 | - Add Android 15 SDK support, Configure Android lint, deprecation fixes by @MV-GH in [#1823](https://github.com/LemmyNet/jerboa/pull/1823) 16 | - Add Lemmy Donation Dialog by @iByteABit256 in [#1813](https://github.com/LemmyNet/jerboa/pull/1813) 17 | - Add Image Proxy endpoint support to Share/Download actions by @MV-GH in [#1814](https://github.com/LemmyNet/jerboa/pull/1814) 18 | - Fixes: Webp images using image_proxy can't be saved in Android 10+ by @MV-GH in [#1801](https://github.com/LemmyNet/jerboa/pull/1801) 19 | - Fix rare crash on switching account #1757 by @MV-GH in [#1758](https://github.com/LemmyNet/jerboa/pull/1758) 20 | - Fixing bug with torrent magnet link posts. by @dessalines in [#1724](https://github.com/LemmyNet/jerboa/pull/1724) 21 | - Adding an image upload button for custom thumbnails. by @dessalines in [#1725](https://github.com/LemmyNet/jerboa/pull/1725) 22 | - Create strings.xml (zh) by @BingoKingo in [#1722](https://github.com/LemmyNet/jerboa/pull/1722) 23 | - Redesign for blocks screen by @rodrigo-fm in [#1718](https://github.com/LemmyNet/jerboa/pull/1718) 24 | - Bump LemmyApi to Support Lemmy 0.19.7 Features by @MV-GH in [#1719](https://github.com/LemmyNet/jerboa/pull/1719) 25 | - Fixing signing config. by @dessalines in [#1708](https://github.com/LemmyNet/jerboa/pull/1708) 26 | - Update Norwegian Nynorsk translation by @huftis in [#1695](https://github.com/LemmyNet/jerboa/pull/1695) 27 | - Adding the ability to export / import the database. by @dessalines in [#1685](https://github.com/LemmyNet/jerboa/pull/1685) 28 | - Running renovate every weekend. by @dessalines in [#1686](https://github.com/LemmyNet/jerboa/pull/1686) 29 | - Updating git cliff. by @dessalines in [#1682](https://github.com/LemmyNet/jerboa/pull/1682) 30 | 31 | ## New Contributors 32 | 33 | - @BingoKingo made their first contribution in [#1722](https://github.com/LemmyNet/jerboa/pull/1722) 34 | 35 | **Full Changelog**: https://github.com/LemmyNet/jerboa/compare/0.0.77...0.0.79 36 | 37 | 38 | -------------------------------------------------------------------------------- /app/src/test/java/com/jerboa/ui/components/common/LemmyLinkPluginTest.kt: -------------------------------------------------------------------------------- 1 | package com.jerboa.ui.components.common 2 | 3 | import junitparams.JUnitParamsRunner 4 | import junitparams.Parameters 5 | import org.junit.Assert.assertEquals 6 | import org.junit.Assert.assertFalse 7 | import org.junit.Assert.assertTrue 8 | import org.junit.Test 9 | import org.junit.runner.RunWith 10 | 11 | @RunWith(JUnitParamsRunner::class) 12 | class LemmyLinkPluginTest { 13 | @Test 14 | @Parameters(method = "communitySuccessCases") 15 | fun testCommunityValid( 16 | pattern: String, 17 | fullMatch: String, 18 | community: String, 19 | instance: String?, 20 | ) { 21 | val matcher = lemmyCommunityPattern.matcher(pattern) 22 | 23 | assertTrue(matcher.find()) 24 | assertEquals(fullMatch, matcher.group(0)) 25 | assertEquals(community, matcher.group(1)) 26 | assertEquals(instance, matcher.group(2)) 27 | } 28 | 29 | @Test 30 | @Parameters( 31 | value = [ 32 | "a!community", 33 | "!!community@instance.ml", 34 | "!co", 35 | ], 36 | ) 37 | fun testCommunityInvalid(pattern: String) { 38 | assertFalse(lemmyCommunityPattern.matcher(pattern).find()) 39 | } 40 | 41 | @Test 42 | @Parameters(method = "userSuccessCases") 43 | fun testUserValid( 44 | pattern: String, 45 | fullMatch: String, 46 | user: String, 47 | instance: String?, 48 | ) { 49 | val matcher = lemmyUserPattern.matcher(pattern) 50 | 51 | assertTrue(matcher.find()) 52 | assertEquals(fullMatch, matcher.group(0)) 53 | assertEquals(user, matcher.group(1)) 54 | assertEquals(instance, matcher.group(2)) 55 | } 56 | 57 | @Test 58 | @Parameters( 59 | value = [ 60 | "a@user", 61 | "!@user@instance.ml", 62 | "@co", 63 | ], 64 | ) 65 | fun testUserInvalid(pattern: String) { 66 | assertFalse(lemmyUserPattern.matcher(pattern).find()) 67 | } 68 | 69 | fun communitySuccessCases() = 70 | listOf( 71 | listOf("!community", "!community", "community", null), 72 | listOf(" !community.", "!community", "community", null), 73 | listOf("!community@instance.ml", "!community@instance.ml", "community", "instance.ml"), 74 | listOf(" !community@instance.ml", "!community@instance.ml", "community", "instance.ml"), 75 | listOf("!community@instance.ml!", "!community@instance.ml", "community", "instance.ml"), 76 | ) 77 | 78 | fun userSuccessCases() = 79 | listOf( 80 | listOf("@user", "@user", "user", null), 81 | listOf(" @user.", "@user", "user", null), 82 | listOf("@user@instance.ml", "@user@instance.ml", "user", "instance.ml"), 83 | listOf(" @user@instance.ml", "@user@instance.ml", "user", "instance.ml"), 84 | listOf("@user@instance.ml!", "@user@instance.ml", "user", "instance.ml"), 85 | ) 86 | } 87 | -------------------------------------------------------------------------------- /app/src/main/java/com/jerboa/ui/components/privatemessage/PrivateMessageReply.kt: -------------------------------------------------------------------------------- 1 | 2 | package com.jerboa.ui.components.privatemessage 3 | 4 | import androidx.compose.foundation.layout.Column 5 | import androidx.compose.foundation.layout.padding 6 | import androidx.compose.foundation.rememberScrollState 7 | import androidx.compose.foundation.text.selection.SelectionContainer 8 | import androidx.compose.foundation.verticalScroll 9 | import androidx.compose.material3.HorizontalDivider 10 | import androidx.compose.material3.Text 11 | import androidx.compose.runtime.Composable 12 | import androidx.compose.ui.Modifier 13 | import androidx.compose.ui.res.stringResource 14 | import androidx.compose.ui.text.input.TextFieldValue 15 | import androidx.compose.ui.tooling.preview.Preview 16 | import com.jerboa.R 17 | import com.jerboa.datatypes.samplePrivateMessageView 18 | import com.jerboa.db.entity.Account 19 | import com.jerboa.ui.components.common.MarkdownTextField 20 | import com.jerboa.ui.theme.LARGE_PADDING 21 | import com.jerboa.ui.theme.MEDIUM_PADDING 22 | import it.vercruysse.lemmyapi.datatypes.PersonId 23 | import it.vercruysse.lemmyapi.datatypes.PrivateMessageView 24 | 25 | @Composable 26 | fun RepliedPrivateMessage( 27 | privateMessageView: PrivateMessageView, 28 | onPersonClick: (personId: PersonId) -> Unit, 29 | showAvatar: Boolean, 30 | ) { 31 | Column(modifier = Modifier.padding(MEDIUM_PADDING)) { 32 | PrivateMessageHeader( 33 | privateMessageView = privateMessageView, 34 | onPersonClick = onPersonClick, 35 | myPersonId = privateMessageView.recipient.id, 36 | showAvatar = showAvatar, 37 | ) 38 | SelectionContainer { 39 | Text(text = privateMessageView.private_message.content) 40 | } 41 | } 42 | } 43 | 44 | @Preview 45 | @Composable 46 | fun RepliedPrivateMessagePreview() { 47 | RepliedPrivateMessage( 48 | privateMessageView = samplePrivateMessageView, 49 | onPersonClick = {}, 50 | showAvatar = true, 51 | ) 52 | } 53 | 54 | @Composable 55 | fun PrivateMessageReply( 56 | privateMessageView: PrivateMessageView, 57 | reply: TextFieldValue, 58 | onReplyChange: (TextFieldValue) -> Unit, 59 | onPersonClick: (personId: PersonId) -> Unit, 60 | account: Account, 61 | modifier: Modifier = Modifier, 62 | showAvatar: Boolean, 63 | ) { 64 | val scrollState = rememberScrollState() 65 | 66 | Column( 67 | modifier = modifier.verticalScroll(scrollState), 68 | ) { 69 | RepliedPrivateMessage( 70 | privateMessageView = privateMessageView, 71 | onPersonClick = onPersonClick, 72 | showAvatar = showAvatar, 73 | ) 74 | HorizontalDivider(modifier = Modifier.padding(vertical = LARGE_PADDING)) 75 | MarkdownTextField( 76 | text = reply, 77 | onTextChange = onReplyChange, 78 | account = account, 79 | placeholder = stringResource(R.string.private_message_reply_type_your_message_placeholder), 80 | ) 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /app/src/main/java/com/jerboa/model/PostLikesViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.jerboa.model 2 | 3 | import androidx.compose.runtime.getValue 4 | import androidx.compose.runtime.mutableLongStateOf 5 | import androidx.compose.runtime.mutableStateOf 6 | import androidx.compose.runtime.setValue 7 | import androidx.lifecycle.ViewModel 8 | import androidx.lifecycle.ViewModelProvider 9 | import androidx.lifecycle.viewModelScope 10 | import androidx.lifecycle.viewmodel.CreationExtras 11 | import com.jerboa.VIEW_VOTES_LIMIT 12 | import com.jerboa.api.API 13 | import com.jerboa.api.ApiState 14 | import com.jerboa.api.toApiState 15 | import com.jerboa.getDeduplicateMerge 16 | import it.vercruysse.lemmyapi.datatypes.ListPostLikes 17 | import it.vercruysse.lemmyapi.datatypes.ListPostLikesResponse 18 | import it.vercruysse.lemmyapi.datatypes.PostId 19 | import kotlinx.coroutines.launch 20 | 21 | class PostLikesViewModel( 22 | val id: PostId, 23 | ) : ViewModel() { 24 | var likesRes: ApiState by mutableStateOf(ApiState.Empty) 25 | private set 26 | private var page by mutableLongStateOf(1) 27 | 28 | init { 29 | getLikes() 30 | } 31 | 32 | fun resetPage() { 33 | page = 1 34 | } 35 | 36 | fun getLikes(state: ApiState = ApiState.Loading) { 37 | viewModelScope.launch { 38 | likesRes = state 39 | likesRes = API.getInstance().listPostLikes(getForm()).toApiState() 40 | } 41 | } 42 | 43 | private fun getForm(): ListPostLikes = 44 | ListPostLikes( 45 | post_id = id, 46 | limit = VIEW_VOTES_LIMIT, 47 | page = page, 48 | ) 49 | 50 | fun appendLikes() { 51 | viewModelScope.launch { 52 | val oldRes = likesRes 53 | when (oldRes) { 54 | is ApiState.Success -> likesRes = ApiState.Appending(oldRes.data) 55 | else -> return@launch 56 | } 57 | 58 | page += 1 59 | val newRes = API.getInstance().listPostLikes(getForm()).toApiState() 60 | 61 | likesRes = 62 | when (newRes) { 63 | is ApiState.Success -> { 64 | val appended = 65 | getDeduplicateMerge( 66 | oldRes.data.post_likes, 67 | newRes.data.post_likes, 68 | ) { it.creator.id } 69 | 70 | ApiState.Success(oldRes.data.copy(post_likes = appended)) 71 | } 72 | 73 | else -> { 74 | oldRes 75 | } 76 | } 77 | } 78 | } 79 | 80 | companion object { 81 | class Factory( 82 | private val id: PostId, 83 | ) : ViewModelProvider.Factory { 84 | @Suppress("UNCHECKED_CAST") 85 | override fun create( 86 | modelClass: Class, 87 | extras: CreationExtras, 88 | ): T = PostLikesViewModel(id) as T 89 | } 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /cliff.toml: -------------------------------------------------------------------------------- 1 | # git-cliff ~ configuration file 2 | # https://git-cliff.org/docs/configuration 3 | 4 | [remote.github] 5 | owner = "LemmyNet" 6 | repo = "jerboa" 7 | # token = "" 8 | 9 | [changelog] 10 | # template for the changelog body 11 | # https://keats.github.io/tera/docs/#introduction 12 | body = """ 13 | ## What's Changed 14 | 15 | {%- if version %} in {{ version }}{%- endif -%} 16 | {% for commit in commits %} 17 | {% if commit.remote.pr_title -%} 18 | {%- set commit_message = commit.remote.pr_title -%} 19 | {%- else -%} 20 | {%- set commit_message = commit.message -%} 21 | {%- endif -%} 22 | * {{ commit_message | split(pat="\n") | first | trim }}\ 23 | {% if commit.remote.username %} by @{{ commit.remote.username }}{%- endif -%} 24 | {% if commit.remote.pr_number %} in \ 25 | [#{{ commit.remote.pr_number }}]({{ self::remote_url() }}/pull/{{ commit.remote.pr_number }}) \ 26 | {%- endif %} 27 | {%- endfor -%} 28 | 29 | {%- if github -%} 30 | {% if github.contributors | filter(attribute="is_first_time", value=true) | length != 0 %} 31 | {% raw %}\n{% endraw -%} 32 | ## New Contributors 33 | {%- endif %}\ 34 | {% for contributor in github.contributors | filter(attribute="is_first_time", value=true) %} 35 | * @{{ contributor.username }} made their first contribution 36 | {%- if contributor.pr_number %} in \ 37 | [#{{ contributor.pr_number }}]({{ self::remote_url() }}/pull/{{ contributor.pr_number }}) \ 38 | {%- endif %} 39 | {%- endfor -%} 40 | {%- endif -%} 41 | 42 | {% if version %} 43 | {% if previous.version %} 44 | **Full Changelog**: {{ self::remote_url() }}/compare/{{ previous.version }}...{{ version }} 45 | {% endif %} 46 | {% else -%} 47 | {% raw %}\n{% endraw %} 48 | {% endif %} 49 | 50 | {%- macro remote_url() -%} 51 | https://github.com/{{ remote.github.owner }}/{{ remote.github.repo }} 52 | {%- endmacro -%} 53 | """ 54 | # remove the leading and trailing whitespace from the template 55 | trim = true 56 | # template for the changelog footer 57 | footer = """ 58 | 59 | """ 60 | # postprocessors 61 | postprocessors = [] 62 | 63 | [git] 64 | # parse the commits based on https://www.conventionalcommits.org 65 | conventional_commits = false 66 | # filter out the commits that are not conventional 67 | filter_unconventional = true 68 | # process each line of a commit as an individual commit 69 | split_commits = false 70 | # regex for preprocessing the commit messages 71 | commit_preprocessors = [ 72 | # remove issue numbers from commits 73 | { pattern = '\((\w+\s)?#([0-9]+)\)', replace = "" }, 74 | ] 75 | commit_parsers = [ 76 | { field = "author.name", pattern = "renovate", skip = true }, 77 | { field = "author.name", pattern = "Weblate", skip = true }, 78 | { field = "message", pattern = "Weblate", skip = true }, 79 | { field = "message", pattern = "Upping version", skip = true }, 80 | ] 81 | # filter out the commits that are not matched by commit parsers 82 | filter_commits = false 83 | # sort the tags topologically 84 | topo_order = false 85 | # sort the commits inside sections by oldest/newest order 86 | sort_commits = "newest" 87 | -------------------------------------------------------------------------------- /app/src/main/java/com/jerboa/model/CommentLikesViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.jerboa.model 2 | 3 | import androidx.compose.runtime.getValue 4 | import androidx.compose.runtime.mutableLongStateOf 5 | import androidx.compose.runtime.mutableStateOf 6 | import androidx.compose.runtime.setValue 7 | import androidx.lifecycle.ViewModel 8 | import androidx.lifecycle.ViewModelProvider 9 | import androidx.lifecycle.viewModelScope 10 | import androidx.lifecycle.viewmodel.CreationExtras 11 | import com.jerboa.VIEW_VOTES_LIMIT 12 | import com.jerboa.api.API 13 | import com.jerboa.api.ApiState 14 | import com.jerboa.api.toApiState 15 | import com.jerboa.getDeduplicateMerge 16 | import it.vercruysse.lemmyapi.datatypes.CommentId 17 | import it.vercruysse.lemmyapi.datatypes.ListCommentLikes 18 | import it.vercruysse.lemmyapi.datatypes.ListCommentLikesResponse 19 | import kotlinx.coroutines.launch 20 | 21 | class CommentLikesViewModel( 22 | val id: CommentId, 23 | ) : ViewModel() { 24 | var likesRes: ApiState by mutableStateOf(ApiState.Empty) 25 | private set 26 | private var page by mutableLongStateOf(1) 27 | 28 | init { 29 | getLikes() 30 | } 31 | 32 | fun resetPage() { 33 | page = 1 34 | } 35 | 36 | fun getLikes(state: ApiState = ApiState.Loading) { 37 | viewModelScope.launch { 38 | likesRes = state 39 | likesRes = API.getInstance().listCommentLikes(getForm()).toApiState() 40 | } 41 | } 42 | 43 | private fun getForm(): ListCommentLikes = 44 | ListCommentLikes( 45 | comment_id = id, 46 | limit = VIEW_VOTES_LIMIT, 47 | page = page, 48 | ) 49 | 50 | fun appendLikes() { 51 | viewModelScope.launch { 52 | val oldRes = likesRes 53 | when (oldRes) { 54 | is ApiState.Success -> likesRes = ApiState.Appending(oldRes.data) 55 | else -> return@launch 56 | } 57 | 58 | page += 1 59 | val newRes = API.getInstance().listCommentLikes(getForm()).toApiState() 60 | 61 | likesRes = 62 | when (newRes) { 63 | is ApiState.Success -> { 64 | val appended = 65 | getDeduplicateMerge( 66 | oldRes.data.comment_likes, 67 | newRes.data.comment_likes, 68 | ) { it.creator.id } 69 | 70 | ApiState.Success(oldRes.data.copy(comment_likes = appended)) 71 | } 72 | 73 | else -> { 74 | oldRes 75 | } 76 | } 77 | } 78 | } 79 | 80 | companion object { 81 | class Factory( 82 | private val id: CommentId, 83 | ) : ViewModelProvider.Factory { 84 | @Suppress("UNCHECKED_CAST") 85 | override fun create( 86 | modelClass: Class, 87 | extras: CreationExtras, 88 | ): T = CommentLikesViewModel(id) as T 89 | } 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /benchmarks/src/main/java/com/jerboa/benchmarks/StartupBenchmarks.kt: -------------------------------------------------------------------------------- 1 | package com.jerboa.benchmarks 2 | 3 | import androidx.benchmark.macro.BaselineProfileMode 4 | import androidx.benchmark.macro.CompilationMode 5 | import androidx.benchmark.macro.StartupMode 6 | import androidx.benchmark.macro.StartupTimingMetric 7 | import androidx.benchmark.macro.junit4.MacrobenchmarkRule 8 | import androidx.test.ext.junit.runners.AndroidJUnit4 9 | import androidx.test.filters.LargeTest 10 | import androidx.test.platform.app.InstrumentationRegistry 11 | import com.jerboa.actions.closeChangeLogIfOpen 12 | import com.jerboa.actions.waitUntilPostsActuallyVisible 13 | import org.junit.Rule 14 | import org.junit.Test 15 | import org.junit.runner.RunWith 16 | 17 | /** 18 | * This test class benchmarks the speed of app startup. 19 | * Run this benchmark to verify how effective a Baseline Profile is. 20 | * It does this by comparing [CompilationMode.None], which represents the app with no Baseline 21 | * Profiles optimizations, and [CompilationMode.Partial], which uses Baseline Profiles. 22 | * 23 | * Run this benchmark to see startup measurements and captured system traces for verifying 24 | * the effectiveness of your Baseline Profiles. You can run it directly from Android 25 | * Studio as an instrumentation test, or run all benchmarks for a variant, for example benchmarkRelease, 26 | * with this Gradle task: 27 | * ``` 28 | * ./gradlew :benchmarks:connectedBenchmarkReleaseAndroidTest -Pandroid.testInstrumentationRunnerArguments.androidx.benchmark.enabledRules=Macrobenchmark 29 | * ``` 30 | * 31 | * You should run the benchmarks on a physical device, not an Android emulator, because the 32 | * emulator doesn't represent real world performance and shares system resources with its host. 33 | * 34 | * For more information, see the [Macrobenchmark documentation](https://d.android.com/macrobenchmark#create-macrobenchmark) 35 | * and the [instrumentation arguments documentation](https://d.android.com/topic/performance/benchmarking/macrobenchmark-instrumentation-args). 36 | **/ 37 | @RunWith(AndroidJUnit4::class) 38 | @LargeTest 39 | class StartupBenchmarks { 40 | @get:Rule 41 | val rule = MacrobenchmarkRule() 42 | 43 | @Test 44 | fun startupCompilationNone() = benchmark(CompilationMode.None()) 45 | 46 | @Test 47 | fun startupCompilationBaselineProfiles() = benchmark(CompilationMode.Partial(BaselineProfileMode.Require)) 48 | 49 | private fun benchmark(compilationMode: CompilationMode) { 50 | rule.measureRepeated( 51 | packageName = InstrumentationRegistry.getArguments().getString("targetAppId") 52 | ?: throw Exception("targetAppId not passed as instrumentation runner arg"), 53 | metrics = listOf(StartupTimingMetric()), 54 | compilationMode = compilationMode, 55 | startupMode = StartupMode.COLD, 56 | iterations = 10, 57 | setupBlock = { 58 | pressHome() 59 | }, 60 | measureBlock = { 61 | startActivityAndWait() 62 | closeChangeLogIfOpen() 63 | waitUntilPostsActuallyVisible(true, 3_000) 64 | }, 65 | ) 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /app/src/main/java/com/jerboa/ui/components/home/legal/SiteLegalScreen.kt: -------------------------------------------------------------------------------- 1 | package com.jerboa.ui.components.home.legal 2 | 3 | import android.util.Log 4 | import androidx.compose.foundation.layout.Column 5 | import androidx.compose.foundation.layout.padding 6 | import androidx.compose.foundation.rememberScrollState 7 | import androidx.compose.foundation.verticalScroll 8 | import androidx.compose.material3.ExperimentalMaterial3Api 9 | import androidx.compose.material3.MaterialTheme 10 | import androidx.compose.material3.Scaffold 11 | import androidx.compose.runtime.Composable 12 | import androidx.compose.ui.Modifier 13 | import androidx.compose.ui.res.stringResource 14 | import com.jerboa.R 15 | import com.jerboa.api.ApiState 16 | import com.jerboa.model.SiteViewModel 17 | import com.jerboa.ui.components.common.ApiEmptyText 18 | import com.jerboa.ui.components.common.ApiErrorText 19 | import com.jerboa.ui.components.common.LoadingBar 20 | import com.jerboa.ui.components.common.MyMarkdownText 21 | import com.jerboa.ui.components.common.SimpleTopAppBar 22 | import com.jerboa.ui.theme.MEDIUM_PADDING 23 | 24 | @OptIn(ExperimentalMaterial3Api::class) 25 | @Composable 26 | fun SiteLegalScreen( 27 | siteViewModel: SiteViewModel, 28 | onBackClick: () -> Unit, 29 | ) { 30 | Log.d("jerboa", "got to site legal screen") 31 | 32 | val scrollState = rememberScrollState() 33 | 34 | val title = 35 | when (val siteRes = siteViewModel.siteRes) { 36 | is ApiState.Success -> { 37 | stringResource(R.string.site_legal_info_name, siteRes.data.site_view.site.name) 38 | } 39 | 40 | else -> { 41 | stringResource(R.string.loading) 42 | } 43 | } 44 | 45 | Scaffold( 46 | topBar = { 47 | SimpleTopAppBar( 48 | text = title, 49 | onBackClick, 50 | ) 51 | }, 52 | content = { padding -> 53 | when (val siteRes = siteViewModel.siteRes) { 54 | ApiState.Empty -> { 55 | ApiEmptyText() 56 | } 57 | 58 | is ApiState.Failure -> { 59 | ApiErrorText(siteRes.msg) 60 | } 61 | 62 | ApiState.Loading -> { 63 | LoadingBar(padding) 64 | } 65 | 66 | is ApiState.Success -> { 67 | Column( 68 | modifier = 69 | Modifier 70 | .padding(padding) 71 | .verticalScroll(scrollState), 72 | ) { 73 | siteRes.data.site_view.local_site.legal_information?.let { 74 | MyMarkdownText( 75 | modifier = Modifier.padding(horizontal = MEDIUM_PADDING), 76 | markdown = it, 77 | color = MaterialTheme.colorScheme.outline, 78 | onClick = {}, 79 | ) 80 | } 81 | } 82 | } 83 | 84 | else -> {} 85 | } 86 | }, 87 | ) 88 | } 89 | -------------------------------------------------------------------------------- /gradlew.bat: -------------------------------------------------------------------------------- 1 | @rem 2 | @rem Copyright 2015 the original author or authors. 3 | @rem 4 | @rem Licensed under the Apache License, Version 2.0 (the "License"); 5 | @rem you may not use this file except in compliance with the License. 6 | @rem You may obtain a copy of the License at 7 | @rem 8 | @rem https://www.apache.org/licenses/LICENSE-2.0 9 | @rem 10 | @rem Unless required by applicable law or agreed to in writing, software 11 | @rem distributed under the License is distributed on an "AS IS" BASIS, 12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | @rem See the License for the specific language governing permissions and 14 | @rem limitations under the License. 15 | @rem 16 | @rem SPDX-License-Identifier: Apache-2.0 17 | @rem 18 | 19 | @if "%DEBUG%"=="" @echo off 20 | @rem ########################################################################## 21 | @rem 22 | @rem Gradle startup script for Windows 23 | @rem 24 | @rem ########################################################################## 25 | 26 | @rem Set local scope for the variables with windows NT shell 27 | if "%OS%"=="Windows_NT" setlocal 28 | 29 | set DIRNAME=%~dp0 30 | if "%DIRNAME%"=="" set DIRNAME=. 31 | @rem This is normally unused 32 | set APP_BASE_NAME=%~n0 33 | set APP_HOME=%DIRNAME% 34 | 35 | @rem Resolve any "." and ".." in APP_HOME to make it shorter. 36 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi 37 | 38 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 39 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" 40 | 41 | @rem Find java.exe 42 | if defined JAVA_HOME goto findJavaFromJavaHome 43 | 44 | set JAVA_EXE=java.exe 45 | %JAVA_EXE% -version >NUL 2>&1 46 | if %ERRORLEVEL% equ 0 goto execute 47 | 48 | echo. 1>&2 49 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 50 | echo. 1>&2 51 | echo Please set the JAVA_HOME variable in your environment to match the 1>&2 52 | echo location of your Java installation. 1>&2 53 | 54 | goto fail 55 | 56 | :findJavaFromJavaHome 57 | set JAVA_HOME=%JAVA_HOME:"=% 58 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 59 | 60 | if exist "%JAVA_EXE%" goto execute 61 | 62 | echo. 1>&2 63 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 64 | echo. 1>&2 65 | echo Please set the JAVA_HOME variable in your environment to match the 1>&2 66 | echo location of your Java installation. 1>&2 67 | 68 | goto fail 69 | 70 | :execute 71 | @rem Setup the command line 72 | 73 | 74 | 75 | @rem Execute Gradle 76 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* 77 | 78 | :end 79 | @rem End local scope for the variables with windows NT shell 80 | if %ERRORLEVEL% equ 0 goto mainEnd 81 | 82 | :fail 83 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 84 | rem the _cmd.exe /c_ return code! 85 | set EXIT_CODE=%ERRORLEVEL% 86 | if %EXIT_CODE% equ 0 set EXIT_CODE=1 87 | if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% 88 | exit /b %EXIT_CODE% 89 | 90 | :mainEnd 91 | if "%OS%"=="Windows_NT" endlocal 92 | 93 | :omega 94 | --------------------------------------------------------------------------------