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 |
5 |
6 |
7 |
8 |
11 |
16 |
17 |
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 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
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 |
--------------------------------------------------------------------------------