├── htmlText
├── .gitignore
├── consumer-rules.pro
├── src
│ ├── main
│ │ ├── AndroidManifest.xml
│ │ └── java
│ │ │ └── io
│ │ │ └── github
│ │ │ └── cooaer
│ │ │ └── htmltext
│ │ │ ├── StateImage.kt
│ │ │ └── ClickableText.kt
│ ├── test
│ │ └── java
│ │ │ └── io
│ │ │ └── github
│ │ │ └── cooaer
│ │ │ └── htmlview
│ │ │ └── ExampleUnitTest.kt
│ └── androidTest
│ │ └── java
│ │ └── io
│ │ └── github
│ │ └── cooaer
│ │ └── htmlview
│ │ └── ExampleInstrumentedTest.kt
├── proguard-rules.pro
└── build.gradle
├── metadata
├── en-US
│ ├── title.txt
│ ├── changelogs
│ │ ├── 100.txt
│ │ └── 101.txt
│ ├── short_description.txt
│ ├── images
│ │ ├── icon.png
│ │ └── phoneScreenshots
│ │ │ ├── 1.jpg
│ │ │ ├── 2.jpg
│ │ │ ├── 3.jpg
│ │ │ ├── 4.jpg
│ │ │ ├── 5.jpg
│ │ │ └── 6.jpg
│ └── full_description.txt
└── zh-CN
│ ├── changelogs
│ ├── 100.txt
│ └── 101.txt
│ ├── short_description.txt
│ └── full_description.txt
├── app
├── src
│ ├── main
│ │ ├── java
│ │ │ └── io
│ │ │ │ └── github
│ │ │ │ └── v2compose
│ │ │ │ ├── ui
│ │ │ │ ├── common
│ │ │ │ │ ├── LoginComposables.kt
│ │ │ │ │ ├── CloseButton.kt
│ │ │ │ │ ├── Keyboard.kt
│ │ │ │ │ ├── ScaffoldComposables.kt
│ │ │ │ │ ├── LazyPagingItemsWorkaround.kt
│ │ │ │ │ ├── NodeComposables.kt
│ │ │ │ │ ├── HtmlAlertDialog.kt
│ │ │ │ │ ├── StateList.kt
│ │ │ │ │ ├── TextAlertDialog.kt
│ │ │ │ │ ├── PullToRefresh.kt
│ │ │ │ │ ├── Divider.kt
│ │ │ │ │ ├── HtmlComposables.kt
│ │ │ │ │ ├── AutoFillModifier.kt
│ │ │ │ │ ├── SimpleNode.kt
│ │ │ │ │ └── ListDialog.kt
│ │ │ │ ├── topic
│ │ │ │ │ ├── bean
│ │ │ │ │ │ ├── ReplyWrapper.kt
│ │ │ │ │ │ └── TopicInfoWrapper.kt
│ │ │ │ │ └── TopicNavigation.kt
│ │ │ │ ├── gallery
│ │ │ │ │ ├── composables
│ │ │ │ │ │ └── PopupImage.kt
│ │ │ │ │ ├── GalleryViewModel.kt
│ │ │ │ │ └── GalleryNavigation.kt
│ │ │ │ ├── HandleSnackbarMessage.kt
│ │ │ │ ├── login
│ │ │ │ │ ├── twostep
│ │ │ │ │ │ └── TwoStepLoginNavigation.kt
│ │ │ │ │ ├── google
│ │ │ │ │ │ ├── GoogleLoginViewModel.kt
│ │ │ │ │ │ └── GoogleLoginNavigation.kt
│ │ │ │ │ ├── LoginNavigation.kt
│ │ │ │ │ └── LoginScreenState.kt
│ │ │ │ ├── main
│ │ │ │ │ ├── mine
│ │ │ │ │ │ ├── nodes
│ │ │ │ │ │ │ ├── MyNodesNavigation.kt
│ │ │ │ │ │ │ └── MyNodesViewModel.kt
│ │ │ │ │ │ ├── topics
│ │ │ │ │ │ │ ├── MyTopicsNavigation.kt
│ │ │ │ │ │ │ └── MyTopicsViewModel.kt
│ │ │ │ │ │ ├── following
│ │ │ │ │ │ │ ├── MyFollowingNavigation.kt
│ │ │ │ │ │ │ └── MyFollowingViewModel.kt
│ │ │ │ │ │ └── MineContentState.kt
│ │ │ │ │ ├── home
│ │ │ │ │ │ ├── recent
│ │ │ │ │ │ │ └── RecentViewModel.kt
│ │ │ │ │ │ └── HomeViewModel.kt
│ │ │ │ │ ├── MainScreenState.kt
│ │ │ │ │ ├── composables
│ │ │ │ │ │ └── ClickHandler.kt
│ │ │ │ │ ├── notifications
│ │ │ │ │ │ └── NotificationViewModel.kt
│ │ │ │ │ └── MainNavigation.kt
│ │ │ │ ├── BaseScreenState.kt
│ │ │ │ ├── settings
│ │ │ │ │ ├── SettingsNavigation.kt
│ │ │ │ │ ├── compoables
│ │ │ │ │ │ └── AutoCheckInPermissions.kt
│ │ │ │ │ └── SettingsScreenState.kt
│ │ │ │ ├── BaseViewModel.kt
│ │ │ │ ├── theme
│ │ │ │ │ └── Type.kt
│ │ │ │ ├── webview
│ │ │ │ │ ├── WebViewNavigation.kt
│ │ │ │ │ └── client
│ │ │ │ │ │ └── V2exWebViewClient.kt
│ │ │ │ ├── user
│ │ │ │ │ ├── UserScreenState.kt
│ │ │ │ │ └── UserNavigation.kt
│ │ │ │ ├── supplement
│ │ │ │ │ ├── AddSupplementScreenState.kt
│ │ │ │ │ └── AddSupplementNavigation.kt
│ │ │ │ ├── search
│ │ │ │ │ ├── SearchNavigation.kt
│ │ │ │ │ └── SearchViewModel.kt
│ │ │ │ ├── node
│ │ │ │ │ ├── NodeScreenState.kt
│ │ │ │ │ └── NodeNavigation.kt
│ │ │ │ └── write
│ │ │ │ │ ├── WriteTopicScreenState.kt
│ │ │ │ │ └── WriteTopicNavigation.kt
│ │ │ │ ├── bean
│ │ │ │ ├── Event.kt
│ │ │ │ ├── DarkMode.kt
│ │ │ │ ├── AppSettings.kt
│ │ │ │ ├── ProxyInfo.kt
│ │ │ │ ├── DraftTopic.kt
│ │ │ │ └── Account.kt
│ │ │ │ ├── core
│ │ │ │ ├── error
│ │ │ │ │ └── VisibilityError.kt
│ │ │ │ ├── StringDecoder.kt
│ │ │ │ ├── analytics
│ │ │ │ │ └── IAnalytics.kt
│ │ │ │ ├── extension
│ │ │ │ │ ├── AnyCast.kt
│ │ │ │ │ ├── Days.kt
│ │ │ │ │ ├── HttpStatusCode.kt
│ │ │ │ │ ├── UriString.kt
│ │ │ │ │ ├── RedirectException.kt
│ │ │ │ │ ├── StringList.kt
│ │ │ │ │ ├── AppVersion.kt
│ │ │ │ │ └── DateTimeString.kt
│ │ │ │ ├── UriDecoder.kt
│ │ │ │ ├── di
│ │ │ │ │ ├── DecoderModule.kt
│ │ │ │ │ └── TrackingModule.kt
│ │ │ │ ├── Share.kt
│ │ │ │ ├── LinkHandler.kt
│ │ │ │ ├── result
│ │ │ │ │ └── Result.kt
│ │ │ │ ├── CheckInWorker.kt
│ │ │ │ ├── Browser.kt
│ │ │ │ ├── NotificationCenter.kt
│ │ │ │ └── NavigationWithAnimation.kt
│ │ │ │ ├── repository
│ │ │ │ ├── AppRepository.kt
│ │ │ │ ├── NewsRepository.kt
│ │ │ │ ├── def
│ │ │ │ │ ├── DefaultAppRepository.kt
│ │ │ │ │ ├── DefaultNewsRepository.kt
│ │ │ │ │ ├── DefaultUserRepository.kt
│ │ │ │ │ └── DefaultNodeRepository.kt
│ │ │ │ ├── NodeRepository.kt
│ │ │ │ ├── UserRepository.kt
│ │ │ │ ├── di
│ │ │ │ │ └── DataModule.kt
│ │ │ │ ├── AccountRepository.kt
│ │ │ │ └── TopicRepository.kt
│ │ │ │ ├── network
│ │ │ │ ├── bean
│ │ │ │ │ ├── V2exResult.kt
│ │ │ │ │ ├── IBase.java
│ │ │ │ │ ├── TopicNode.kt
│ │ │ │ │ ├── ThxResponseInfo.java
│ │ │ │ │ ├── BaseInfo.java
│ │ │ │ │ ├── ReplyTopicResultInfo.java
│ │ │ │ │ ├── Node.kt
│ │ │ │ │ ├── LoginResultInfo.java
│ │ │ │ │ ├── Release.kt
│ │ │ │ │ ├── NewUserBannedCreateInfo.java
│ │ │ │ │ ├── TwoStepLoginInfo.java
│ │ │ │ │ ├── UserTopics.kt
│ │ │ │ │ ├── MyNodesInfo.java
│ │ │ │ │ ├── LoginParam.java
│ │ │ │ │ ├── HomePageInfo.java
│ │ │ │ │ ├── DailyInfo.java
│ │ │ │ │ └── BingSearchResultInfo.java
│ │ │ │ ├── NetConstants.kt
│ │ │ │ └── GithubService.kt
│ │ │ │ ├── util
│ │ │ │ ├── DateUtils.java
│ │ │ │ ├── Utils.java
│ │ │ │ ├── RefererUtils.java
│ │ │ │ ├── L.java
│ │ │ │ ├── InetValidator.kt
│ │ │ │ ├── Check.java
│ │ │ │ ├── Logf.kt
│ │ │ │ ├── AvatarUtils.java
│ │ │ │ ├── WebViewProxy.kt
│ │ │ │ └── UriUtils.kt
│ │ │ │ ├── Constants.kt
│ │ │ │ ├── MainActivity.kt
│ │ │ │ ├── V2AppViewModel.kt
│ │ │ │ ├── V2exUri.kt
│ │ │ │ ├── usecase
│ │ │ │ ├── CheckInUseCase.kt
│ │ │ │ ├── CheckForUpdatesUseCase.kt
│ │ │ │ ├── LoadNodesUseCase.kt
│ │ │ │ └── UpdateAccountUseCase.kt
│ │ │ │ ├── datasource
│ │ │ │ ├── SearchPagingSource.kt
│ │ │ │ ├── MyTopicsPagingSource.kt
│ │ │ │ ├── MyFollowingPagingSource.kt
│ │ │ │ ├── UserRepliesDataSource.kt
│ │ │ │ ├── AppStateStore.kt
│ │ │ │ ├── RecentTopicsPagingSource.kt
│ │ │ │ ├── UserTopicsDataSource.kt
│ │ │ │ ├── NotificationsPagingSource.kt
│ │ │ │ └── TopicPagingSource.kt
│ │ │ │ └── AppModule.kt
│ │ ├── ic_launcher-playstore.png
│ │ └── res
│ │ │ ├── drawable-xxxhdpi
│ │ │ ├── gold.png
│ │ │ ├── bronze.png
│ │ │ └── silver.png
│ │ │ ├── drawable-xhdpi
│ │ │ └── logo_sov2ex.png
│ │ │ ├── drawable-xxhdpi
│ │ │ └── googleg_standard_color.png
│ │ │ ├── values
│ │ │ ├── ic_launcher_background.xml
│ │ │ ├── arrays.xml
│ │ │ ├── colors.xml
│ │ │ └── themes.xml
│ │ │ ├── mipmap-anydpi-v26
│ │ │ ├── ic_launcher.xml
│ │ │ └── ic_launcher_round.xml
│ │ │ ├── xml
│ │ │ ├── backup_rules.xml
│ │ │ ├── data_extraction_rules.xml
│ │ │ └── network_security_config.xml
│ │ │ ├── values-night
│ │ │ └── themes.xml
│ │ │ ├── drawable
│ │ │ └── ic_launcher_foreground.xml
│ │ │ └── anim
│ │ │ ├── slide_in_right.xml
│ │ │ └── slide_out_left.xml
│ ├── debug
│ │ └── res
│ │ │ └── values
│ │ │ └── ic_launcher_background.xml
│ ├── foss
│ │ └── kotlin
│ │ │ └── io
│ │ │ └── github
│ │ │ └── v2compose
│ │ │ └── core
│ │ │ └── analytics
│ │ │ └── VendorAnalytics.kt
│ ├── test
│ │ └── java
│ │ │ └── io
│ │ │ └── github
│ │ │ └── cooaer
│ │ │ └── v2compose
│ │ │ └── ExampleUnitTest.kt
│ ├── google
│ │ └── kotlin
│ │ │ └── io
│ │ │ └── github
│ │ │ └── v2compose
│ │ │ └── core
│ │ │ └── analytics
│ │ │ └── VendorAnalytics.kt
│ └── androidTest
│ │ └── java
│ │ └── io
│ │ └── github
│ │ └── v2compose
│ │ └── ExampleInstrumentedTest.kt
├── .gitignore
└── proguard-rules.pro
├── .github
└── images
│ ├── badge-f-droid.png
│ └── badge-github.png
├── gradle
└── wrapper
│ ├── gradle-wrapper.jar
│ └── gradle-wrapper.properties
├── .idea
├── vcs.xml
├── compiler.xml
├── misc.xml
└── gradle.xml
├── .gitignore
├── settings.gradle
└── gradle.properties
/htmlText/.gitignore:
--------------------------------------------------------------------------------
1 | /build
--------------------------------------------------------------------------------
/htmlText/consumer-rules.pro:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/metadata/en-US/title.txt:
--------------------------------------------------------------------------------
1 | V2compose
--------------------------------------------------------------------------------
/metadata/zh-CN/changelogs/100.txt:
--------------------------------------------------------------------------------
1 | 更新:
2 | * 上架 F-Droid;
--------------------------------------------------------------------------------
/metadata/en-US/changelogs/100.txt:
--------------------------------------------------------------------------------
1 | Update:
2 | * Add to F-Droid;
--------------------------------------------------------------------------------
/app/src/main/java/io/github/v2compose/ui/common/LoginComposables.kt:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/metadata/zh-CN/short_description.txt:
--------------------------------------------------------------------------------
1 | 一个 Material You 风格的 V2ex 客户端!
--------------------------------------------------------------------------------
/metadata/en-US/short_description.txt:
--------------------------------------------------------------------------------
1 | A Material You style V2ex client!
--------------------------------------------------------------------------------
/metadata/zh-CN/changelogs/101.txt:
--------------------------------------------------------------------------------
1 | 更新:
2 | * 上架 F-Droid;
3 | 修复:
4 | * Google 登录后跳转主页失败的问题;
--------------------------------------------------------------------------------
/app/.gitignore:
--------------------------------------------------------------------------------
1 | /build
2 | /release/
3 | src/google/google-services.json
4 | /google/
5 | /foss/
6 |
--------------------------------------------------------------------------------
/metadata/en-US/images/icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cooaer/v2compose/HEAD/metadata/en-US/images/icon.png
--------------------------------------------------------------------------------
/.github/images/badge-f-droid.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cooaer/v2compose/HEAD/.github/images/badge-f-droid.png
--------------------------------------------------------------------------------
/.github/images/badge-github.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cooaer/v2compose/HEAD/.github/images/badge-github.png
--------------------------------------------------------------------------------
/gradle/wrapper/gradle-wrapper.jar:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cooaer/v2compose/HEAD/gradle/wrapper/gradle-wrapper.jar
--------------------------------------------------------------------------------
/app/src/main/ic_launcher-playstore.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cooaer/v2compose/HEAD/app/src/main/ic_launcher-playstore.png
--------------------------------------------------------------------------------
/metadata/en-US/changelogs/101.txt:
--------------------------------------------------------------------------------
1 | Updated:
2 | * Add to F-Droid.
3 | Fixes:
4 | * Failed to jump to the home page after Google login;
--------------------------------------------------------------------------------
/app/src/main/java/io/github/v2compose/bean/Event.kt:
--------------------------------------------------------------------------------
1 | package io.github.v2compose.bean
2 |
3 | data class RedirectEvent(val location:String)
--------------------------------------------------------------------------------
/app/src/main/res/drawable-xxxhdpi/gold.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cooaer/v2compose/HEAD/app/src/main/res/drawable-xxxhdpi/gold.png
--------------------------------------------------------------------------------
/app/src/main/res/drawable-xxxhdpi/bronze.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cooaer/v2compose/HEAD/app/src/main/res/drawable-xxxhdpi/bronze.png
--------------------------------------------------------------------------------
/app/src/main/res/drawable-xxxhdpi/silver.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cooaer/v2compose/HEAD/app/src/main/res/drawable-xxxhdpi/silver.png
--------------------------------------------------------------------------------
/metadata/en-US/images/phoneScreenshots/1.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cooaer/v2compose/HEAD/metadata/en-US/images/phoneScreenshots/1.jpg
--------------------------------------------------------------------------------
/metadata/en-US/images/phoneScreenshots/2.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cooaer/v2compose/HEAD/metadata/en-US/images/phoneScreenshots/2.jpg
--------------------------------------------------------------------------------
/metadata/en-US/images/phoneScreenshots/3.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cooaer/v2compose/HEAD/metadata/en-US/images/phoneScreenshots/3.jpg
--------------------------------------------------------------------------------
/metadata/en-US/images/phoneScreenshots/4.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cooaer/v2compose/HEAD/metadata/en-US/images/phoneScreenshots/4.jpg
--------------------------------------------------------------------------------
/metadata/en-US/images/phoneScreenshots/5.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cooaer/v2compose/HEAD/metadata/en-US/images/phoneScreenshots/5.jpg
--------------------------------------------------------------------------------
/metadata/en-US/images/phoneScreenshots/6.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cooaer/v2compose/HEAD/metadata/en-US/images/phoneScreenshots/6.jpg
--------------------------------------------------------------------------------
/app/src/main/res/drawable-xhdpi/logo_sov2ex.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cooaer/v2compose/HEAD/app/src/main/res/drawable-xhdpi/logo_sov2ex.png
--------------------------------------------------------------------------------
/app/src/main/java/io/github/v2compose/bean/DarkMode.kt:
--------------------------------------------------------------------------------
1 | package io.github.v2compose.bean
2 |
3 | enum class DarkMode {
4 | FollowSystem, Off, On
5 | }
--------------------------------------------------------------------------------
/metadata/zh-CN/full_description.txt:
--------------------------------------------------------------------------------
1 | V2compose 是一个界面简洁、交互流畅的 V2ex 客户端!
2 | 特点:
3 | * Material You 风格;
4 | * 编辑器支持 Markdown 格式;
5 | * 自动签到;
6 | * 自定义代理服务器;
--------------------------------------------------------------------------------
/app/src/main/java/io/github/v2compose/core/error/VisibilityError.kt:
--------------------------------------------------------------------------------
1 | package io.github.v2compose.core.error
2 |
3 | class VisibilityError(message: String?) : Error(message)
--------------------------------------------------------------------------------
/app/src/main/res/drawable-xxhdpi/googleg_standard_color.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cooaer/v2compose/HEAD/app/src/main/res/drawable-xxhdpi/googleg_standard_color.png
--------------------------------------------------------------------------------
/htmlText/src/main/AndroidManifest.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
--------------------------------------------------------------------------------
/app/src/debug/res/values/ic_launcher_background.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | #FF0000
4 |
--------------------------------------------------------------------------------
/app/src/main/res/values/ic_launcher_background.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | #FFFFFF
4 |
--------------------------------------------------------------------------------
/app/src/main/java/io/github/v2compose/core/StringDecoder.kt:
--------------------------------------------------------------------------------
1 | package io.github.v2compose.core
2 |
3 | interface StringDecoder {
4 | fun decodeString(encodedString: String): String
5 | }
--------------------------------------------------------------------------------
/.idea/vcs.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/.idea/compiler.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/app/src/main/java/io/github/v2compose/core/analytics/IAnalytics.kt:
--------------------------------------------------------------------------------
1 | package io.github.v2compose.core.analytics
2 |
3 | interface IAnalytics {
4 |
5 | fun startTracking()
6 |
7 | fun stopTracking()
8 |
9 | }
--------------------------------------------------------------------------------
/metadata/en-US/full_description.txt:
--------------------------------------------------------------------------------
1 | V2compose is a V2ex client with simple interface and smooth interaction.
2 | Features:
3 | * Material You style;
4 | * The editor supports Markdown format;
5 | * Automatic check-in;
6 | * Custom proxy server;
--------------------------------------------------------------------------------
/app/src/main/java/io/github/v2compose/repository/AppRepository.kt:
--------------------------------------------------------------------------------
1 | package io.github.v2compose.repository
2 |
3 | import io.github.v2compose.network.bean.Release
4 |
5 | interface AppRepository {
6 |
7 | suspend fun getAppLatestRelease():Release
8 |
9 | }
--------------------------------------------------------------------------------
/app/src/main/java/io/github/v2compose/network/bean/V2exResult.kt:
--------------------------------------------------------------------------------
1 | package io.github.v2compose.network.bean
2 |
3 | data class V2exResult(
4 | val success: Boolean = false,
5 | val message: String = "",
6 | val messageEn: String = "",
7 | val once: Int = -1,
8 | )
--------------------------------------------------------------------------------
/app/src/main/java/io/github/v2compose/core/extension/AnyCast.kt:
--------------------------------------------------------------------------------
1 | package io.github.v2compose.core.extension
2 |
3 | inline fun Any?.castOrNull(): T? = if (this is T) this else null
4 |
5 | inline fun Any?.cast(orElse: () -> T): T = if (this is T) this else orElse()
--------------------------------------------------------------------------------
/app/src/main/res/values/arrays.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | - 主页
5 | - 节点
6 | - 通知
7 | - 我的
8 |
9 |
--------------------------------------------------------------------------------
/gradle/wrapper/gradle-wrapper.properties:
--------------------------------------------------------------------------------
1 | #Wed Dec 28 16:58:55 CST 2022
2 | distributionBase=GRADLE_USER_HOME
3 | distributionUrl=https\://services.gradle.org/distributions/gradle-7.5-bin.zip
4 | distributionPath=wrapper/dists
5 | zipStorePath=wrapper/dists
6 | zipStoreBase=GRADLE_USER_HOME
7 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | *.iml
2 | .gradle
3 | /local.properties
4 | /.idea/caches
5 | /.idea/libraries
6 | /.idea/modules.xml
7 | /.idea/workspace.xml
8 | /.idea/navEditor.xml
9 | /.idea/assetWizardSettings.xml
10 | .DS_Store
11 | /build
12 | /captures
13 | .externalNativeBuild
14 | .cxx
15 | local.properties
16 |
--------------------------------------------------------------------------------
/app/src/main/java/io/github/v2compose/core/UriDecoder.kt:
--------------------------------------------------------------------------------
1 | package io.github.v2compose.core
2 |
3 | import android.net.Uri
4 | import javax.inject.Inject
5 |
6 | class UriDecoder @Inject constructor() : StringDecoder {
7 | override fun decodeString(encodedString: String): String = Uri.decode(encodedString)
8 | }
--------------------------------------------------------------------------------
/app/src/main/java/io/github/v2compose/ui/topic/bean/ReplyWrapper.kt:
--------------------------------------------------------------------------------
1 | package io.github.v2compose.ui.topic.bean
2 |
3 | import io.github.v2compose.network.bean.TopicInfo
4 |
5 | data class ReplyWrapper(
6 | val reply: TopicInfo.Reply,
7 | val thanked: Boolean? = null,
8 | val ignored: Boolean? = null,
9 | )
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
--------------------------------------------------------------------------------
/app/src/main/java/io/github/v2compose/core/extension/Days.kt:
--------------------------------------------------------------------------------
1 | package io.github.v2compose.core.extension
2 |
3 | private const val dayMills = 24 * 60 * 60 * 1000
4 |
5 | fun Long.isBeforeTodayByUTC() = newDayThan(System.currentTimeMillis())
6 |
7 | fun Long.newDayThan(other: Long): Boolean {
8 | return this / dayMills > other / dayMills
9 | }
--------------------------------------------------------------------------------
/app/src/foss/kotlin/io/github/v2compose/core/analytics/VendorAnalytics.kt:
--------------------------------------------------------------------------------
1 | package io.github.v2compose.core.analytics
2 |
3 | import javax.inject.Inject
4 |
5 | class VendorAnalytics @Inject constructor() : IAnalytics {
6 |
7 |
8 | override fun startTracking() {
9 | }
10 |
11 | override fun stopTracking() {
12 | }
13 |
14 | }
--------------------------------------------------------------------------------
/app/src/main/java/io/github/v2compose/network/bean/IBase.java:
--------------------------------------------------------------------------------
1 | package io.github.v2compose.network.bean;
2 |
3 | import me.ghui.fruit.converter.retrofit.IBaseWrapper;
4 |
5 | /**
6 | * Created by ghui on 24/07/2017.
7 | */
8 |
9 | public interface IBase extends IBaseWrapper {
10 | /**
11 | * 某个接口返回业务上的合法性
12 | *
13 | * @return
14 | */
15 | boolean isValid();
16 | }
17 |
--------------------------------------------------------------------------------
/app/src/main/res/values/colors.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | #FFBB86FC
4 | #FF6200EE
5 | #FF3700B3
6 | #FF03DAC5
7 | #FF018786
8 | #FF000000
9 | #FFFFFFFF
10 |
--------------------------------------------------------------------------------
/app/src/main/java/io/github/v2compose/repository/NewsRepository.kt:
--------------------------------------------------------------------------------
1 | package io.github.v2compose.repository
2 |
3 | import androidx.paging.PagingData
4 | import io.github.v2compose.network.bean.NewsInfo
5 | import io.github.v2compose.network.bean.RecentTopics
6 | import kotlinx.coroutines.flow.Flow
7 |
8 | interface NewsRepository {
9 | suspend fun getHomeNews(tab: String): NewsInfo
10 | val recentTopics: Flow>
11 | }
--------------------------------------------------------------------------------
/app/src/main/java/io/github/v2compose/util/DateUtils.java:
--------------------------------------------------------------------------------
1 | package io.github.v2compose.util;
2 |
3 | import java.text.SimpleDateFormat;
4 | import java.util.Date;
5 | import java.util.Locale;
6 |
7 | /**
8 | * Created by ghui on 02/04/2017.
9 | */
10 |
11 | public class DateUtils {
12 |
13 | public static String parseDate(long time) {
14 | return new SimpleDateFormat("HH:mm", Locale.CHINA).format(new Date(time));
15 | }
16 | }
17 |
--------------------------------------------------------------------------------
/app/src/test/java/io/github/cooaer/v2compose/ExampleUnitTest.kt:
--------------------------------------------------------------------------------
1 | package io.github.v2compose
2 |
3 | import org.junit.Test
4 |
5 | import org.junit.Assert.*
6 |
7 | /**
8 | * Example local unit test, which will execute on the development machine (host).
9 | *
10 | * See [testing documentation](http://d.android.com/tools/testing).
11 | */
12 | class ExampleUnitTest {
13 | @Test
14 | fun addition_isCorrect() {
15 | assertEquals(4, 2 + 2)
16 | }
17 | }
--------------------------------------------------------------------------------
/htmlText/src/test/java/io/github/cooaer/htmlview/ExampleUnitTest.kt:
--------------------------------------------------------------------------------
1 | package io.github.cooaer.htmltext
2 |
3 | import org.junit.Test
4 |
5 | import org.junit.Assert.*
6 |
7 | /**
8 | * Example local unit test, which will execute on the development machine (host).
9 | *
10 | * See [testing documentation](http://d.android.com/tools/testing).
11 | */
12 | class ExampleUnitTest {
13 | @Test
14 | fun addition_isCorrect() {
15 | assertEquals(4, 2 + 2)
16 | }
17 | }
--------------------------------------------------------------------------------
/.idea/misc.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
--------------------------------------------------------------------------------
/app/src/main/java/io/github/v2compose/Constants.kt:
--------------------------------------------------------------------------------
1 | package io.github.v2compose
2 |
3 | object Constants {
4 | const val host = "v2ex.com"
5 | const val baseUrl = "https://www.v2ex.com"
6 | const val userPath = "/member/"
7 |
8 | const val source = "https://github.com/cooaer/v2compose"
9 | const val issues = "https://github.com/cooaer/v2compose/issues/new"
10 | const val owner = "cooaer"
11 | const val repo = "v2compose"
12 |
13 | const val topicTitleOverviewMaxLines = 2
14 |
15 | }
--------------------------------------------------------------------------------
/app/src/main/java/io/github/v2compose/network/bean/TopicNode.kt:
--------------------------------------------------------------------------------
1 | package io.github.v2compose.network.bean
2 |
3 | import android.os.Parcelable
4 | import com.google.gson.annotations.SerializedName
5 | import com.squareup.moshi.JsonClass
6 | import kotlinx.parcelize.Parcelize
7 |
8 | @Parcelize
9 | @JsonClass(generateAdapter = true)
10 | data class TopicNode(
11 | val name: String = "",
12 | val title: String = "",
13 | val topics: Int = 0,
14 | val aliases: List = listOf(),
15 | ) : Parcelable
--------------------------------------------------------------------------------
/app/src/main/java/io/github/v2compose/ui/common/CloseButton.kt:
--------------------------------------------------------------------------------
1 | package io.github.v2compose.ui.common
2 |
3 | import androidx.compose.material.icons.Icons
4 | import androidx.compose.material.icons.rounded.Close
5 | import androidx.compose.material3.Icon
6 | import androidx.compose.material3.IconButton
7 | import androidx.compose.runtime.Composable
8 |
9 | @Composable
10 | fun CloseButton(onClick: () -> Unit) {
11 | IconButton(onClick = onClick) {
12 | Icon(Icons.Rounded.Close, "close")
13 | }
14 | }
--------------------------------------------------------------------------------
/settings.gradle:
--------------------------------------------------------------------------------
1 | pluginManagement {
2 | repositories {
3 | gradlePluginPortal()
4 | google()
5 | mavenCentral()
6 | maven { url 'https://jitpack.io' }
7 | }
8 | }
9 | dependencyResolutionManagement {
10 | repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
11 | repositories {
12 | google()
13 | mavenCentral()
14 | maven { url 'https://jitpack.io' }
15 | }
16 | }
17 | rootProject.name = "V2compose"
18 | include ':app'
19 | include ':htmlText'
20 |
--------------------------------------------------------------------------------
/app/src/main/java/io/github/v2compose/core/di/DecoderModule.kt:
--------------------------------------------------------------------------------
1 | package io.github.v2compose.core.di
2 |
3 | import dagger.Binds
4 | import dagger.Module
5 | import dagger.hilt.InstallIn
6 | import dagger.hilt.components.SingletonComponent
7 | import io.github.v2compose.core.StringDecoder
8 | import io.github.v2compose.core.UriDecoder
9 |
10 | @Module
11 | @InstallIn(SingletonComponent::class)
12 | abstract class DecoderModule {
13 | @Binds
14 | abstract fun provideStringDecoder(uriDecoder: UriDecoder): StringDecoder
15 | }
--------------------------------------------------------------------------------
/app/src/main/java/io/github/v2compose/core/Share.kt:
--------------------------------------------------------------------------------
1 | package io.github.v2compose.core
2 |
3 | import android.content.Context
4 | import android.content.Intent
5 |
6 | fun Context.share(title: String, url: String) {
7 | val text = "$title\n$url"
8 | val sendIntent = Intent().apply {
9 | action = Intent.ACTION_SEND
10 | putExtra(Intent.EXTRA_TEXT, text)
11 | type = "text/plain"
12 | }
13 | val shareChooser = Intent.createChooser(sendIntent, null)
14 | startActivity(shareChooser)
15 | }
16 |
17 |
--------------------------------------------------------------------------------
/app/src/main/java/io/github/v2compose/util/Utils.java:
--------------------------------------------------------------------------------
1 | package io.github.v2compose.util;
2 |
3 | import android.text.TextUtils;
4 |
5 | import java.util.List;
6 |
7 | /**
8 | * Created by ghui on 01/04/2017.
9 | */
10 |
11 | public class Utils {
12 | public static int listSize(List list) {
13 | return list == null ? 0 : list.size();
14 | }
15 |
16 | public static String extractDigits(String src) {
17 | if (TextUtils.isEmpty(src)) return "";
18 | return src.replaceAll("\\D+", "");
19 | }
20 |
21 | }
22 |
23 |
--------------------------------------------------------------------------------
/app/src/main/java/io/github/v2compose/core/di/TrackingModule.kt:
--------------------------------------------------------------------------------
1 | package io.github.v2compose.core.di
2 |
3 | import dagger.Binds
4 | import dagger.Module
5 | import dagger.hilt.InstallIn
6 | import dagger.hilt.components.SingletonComponent
7 | import io.github.v2compose.core.analytics.IAnalytics
8 | import io.github.v2compose.core.analytics.VendorAnalytics
9 |
10 | @Module
11 | @InstallIn(SingletonComponent::class)
12 | abstract class TrackingModule {
13 |
14 | @Binds
15 | abstract fun provideAnalytics(analytics: VendorAnalytics): IAnalytics
16 |
17 | }
--------------------------------------------------------------------------------
/app/src/main/res/xml/backup_rules.xml:
--------------------------------------------------------------------------------
1 |
8 |
9 |
13 |
--------------------------------------------------------------------------------
/app/src/main/java/io/github/v2compose/network/bean/ThxResponseInfo.java:
--------------------------------------------------------------------------------
1 | package io.github.v2compose.network.bean;
2 |
3 | import io.github.v2compose.util.Check;
4 | import me.ghui.fruit.Attrs;
5 | import me.ghui.fruit.annotations.Pick;
6 |
7 | /**
8 | * Created by ghui on 22/06/2017.
9 | */
10 |
11 | public class ThxResponseInfo extends BaseInfo {
12 | @Pick(value = "a[href=/balance]", attr = Attrs.HREF)
13 | private String link;
14 |
15 | @Override
16 | public boolean isValid() {
17 | return Check.notEmpty(link);
18 | }
19 | }
20 |
--------------------------------------------------------------------------------
/app/src/main/java/io/github/v2compose/core/extension/HttpStatusCode.kt:
--------------------------------------------------------------------------------
1 | package io.github.v2compose.core.extension
2 |
3 | import okhttp3.internal.http.StatusLine
4 | import java.net.HttpURLConnection
5 |
6 |
7 | val Int.isRedirect: Boolean
8 | get() = when (this) {
9 | StatusLine.HTTP_PERM_REDIRECT,
10 | StatusLine.HTTP_TEMP_REDIRECT,
11 | HttpURLConnection.HTTP_MULT_CHOICE,
12 | HttpURLConnection.HTTP_MOVED_PERM,
13 | HttpURLConnection.HTTP_MOVED_TEMP,
14 | HttpURLConnection.HTTP_SEE_OTHER -> true
15 | else -> false
16 | }
--------------------------------------------------------------------------------
/app/src/main/java/io/github/v2compose/ui/common/Keyboard.kt:
--------------------------------------------------------------------------------
1 | package io.github.v2compose.ui.common
2 |
3 | import androidx.compose.foundation.layout.WindowInsets
4 | import androidx.compose.foundation.layout.ime
5 | import androidx.compose.runtime.Composable
6 | import androidx.compose.runtime.State
7 | import androidx.compose.runtime.rememberUpdatedState
8 | import androidx.compose.ui.platform.LocalDensity
9 |
10 | @Composable
11 | fun keyboardAsState(): State {
12 | val isImeVisible = WindowInsets.ime.getBottom(LocalDensity.current) > 0
13 | return rememberUpdatedState(isImeVisible)
14 | }
--------------------------------------------------------------------------------
/app/src/main/java/io/github/v2compose/ui/common/ScaffoldComposables.kt:
--------------------------------------------------------------------------------
1 | package io.github.v2compose.ui.common
2 |
3 | import androidx.compose.material.icons.Icons
4 | import androidx.compose.material.icons.rounded.ArrowBack
5 | import androidx.compose.material3.Icon
6 | import androidx.compose.material3.IconButton
7 | import androidx.compose.runtime.Composable
8 |
9 | @Composable
10 | fun BackIcon(onBackClick: () -> Unit) {
11 | IconButton(onClick = onBackClick) {
12 | Icon(
13 | Icons.Rounded.ArrowBack,
14 | contentDescription = "back"
15 | )
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/app/src/google/kotlin/io/github/v2compose/core/analytics/VendorAnalytics.kt:
--------------------------------------------------------------------------------
1 | package io.github.v2compose.core.analytics
2 |
3 | import com.google.firebase.analytics.ktx.analytics
4 | import com.google.firebase.ktx.Firebase
5 | import javax.inject.Inject
6 |
7 | class VendorAnalytics @Inject constructor() : IAnalytics {
8 |
9 | private val analytics = Firebase.analytics
10 |
11 | override fun startTracking() {
12 | analytics.setAnalyticsCollectionEnabled(true)
13 | }
14 |
15 | override fun stopTracking() {
16 | analytics.setAnalyticsCollectionEnabled(false)
17 | }
18 |
19 | }
--------------------------------------------------------------------------------
/app/src/main/java/io/github/v2compose/util/RefererUtils.java:
--------------------------------------------------------------------------------
1 | package io.github.v2compose.util;
2 |
3 |
4 | import io.github.v2compose.network.NetConstants;
5 |
6 | /**
7 | * Created by ghui on 14/06/2017.
8 | */
9 |
10 | public interface RefererUtils {
11 |
12 | String TINY_REFER = NetConstants.BASE_URL + "/mission/daily";
13 |
14 | public static String topicReferer(String topicId) {
15 | return NetConstants.BASE_URL + "/t/" + topicId;
16 | }
17 |
18 | public static String userReferer(String username) {
19 | return NetConstants.BASE_URL + "/member/" + username;
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/app/src/main/java/io/github/v2compose/repository/def/DefaultAppRepository.kt:
--------------------------------------------------------------------------------
1 | package io.github.v2compose.repository.def
2 |
3 | import io.github.v2compose.Constants
4 | import io.github.v2compose.network.GithubService
5 | import io.github.v2compose.network.bean.Release
6 | import io.github.v2compose.repository.AppRepository
7 | import javax.inject.Inject
8 |
9 | class DefaultAppRepository @Inject constructor(private val githubService: GithubService) : AppRepository {
10 |
11 | override suspend fun getAppLatestRelease(): Release {
12 | return githubService.getTheLatestRelease(Constants.owner, Constants.repo)
13 | }
14 |
15 | }
--------------------------------------------------------------------------------
/app/src/main/java/io/github/v2compose/core/extension/UriString.kt:
--------------------------------------------------------------------------------
1 | package io.github.v2compose.core.extension
2 |
3 | import android.net.Uri
4 |
5 | fun String.tryParse(): Uri? {
6 | return try {
7 | Uri.parse(this)
8 | } catch (e: Exception) {
9 | e.printStackTrace()
10 | null
11 | }
12 | }
13 |
14 | fun String.fullUrl(baseUrl: String? = null): String {
15 | if (startsWith("//")) {
16 | return "https:$this"
17 | } else if (startsWith("/")) {
18 | if (baseUrl != null) {
19 | return baseUrl.dropLastWhile { it == '/' } + this
20 | }
21 | }
22 | return this
23 | }
--------------------------------------------------------------------------------
/app/src/main/java/io/github/v2compose/bean/AppSettings.kt:
--------------------------------------------------------------------------------
1 | package io.github.v2compose.bean
2 |
3 | data class AppSettings(
4 | val topicRepliesReversed: Boolean = true,
5 | val openInInternalBrowser: Boolean = true,
6 | val darkMode: DarkMode = DarkMode.FollowSystem,
7 | val topicTitleOverview: Boolean = true,
8 | val ignoredReleaseName: String? = null,
9 | val autoCheckIn: Boolean = false,
10 | val searchKeywords: List = listOf(),
11 | val highlightOpReply: Boolean = false,
12 | val replyWithFloor: Boolean = true,
13 | ) {
14 | companion object {
15 | val Default = AppSettings()
16 | }
17 | }
--------------------------------------------------------------------------------
/app/src/main/res/xml/data_extraction_rules.xml:
--------------------------------------------------------------------------------
1 |
6 |
7 |
8 |
12 |
13 |
19 |
--------------------------------------------------------------------------------
/app/src/main/java/io/github/v2compose/network/bean/BaseInfo.java:
--------------------------------------------------------------------------------
1 | package io.github.v2compose.network.bean;
2 |
3 | import java.io.Serializable;
4 |
5 | /**
6 | * Created by ghui on 21/06/2017.
7 | * Check whether the model is valid, if invalide try to find the reason from the rawResponse.
8 | * Such as login expired, no premission, etc.
9 | */
10 |
11 | public abstract class BaseInfo implements IBase, Serializable {
12 | public String rawResponse;
13 |
14 | @Override
15 | public String getResponse() {
16 | return rawResponse;
17 | }
18 |
19 | @Override
20 | public void setResponse(String response) {
21 | rawResponse = response;
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/app/src/main/res/values-night/themes.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
8 |
9 |
10 |
14 |
15 |
16 |
--------------------------------------------------------------------------------
/app/src/main/java/io/github/v2compose/core/extension/RedirectException.kt:
--------------------------------------------------------------------------------
1 | package io.github.v2compose.core.extension
2 |
3 | import retrofit2.HttpException
4 |
5 | fun Exception.isRedirect(location: String): Boolean {
6 | return this is HttpException && code().isRedirect
7 | && response()?.raw()?.header("location") == location
8 | }
9 |
10 | val Exception.isRedirect
11 | get(): Boolean {
12 | return this is HttpException && code().isRedirect
13 | }
14 |
15 | val Exception.redirectLocation
16 | get(): String? {
17 | return if (this is HttpException && code().isRedirect) {
18 | response()?.raw()?.header("location")
19 | } else null
20 | }
--------------------------------------------------------------------------------
/app/src/main/java/io/github/v2compose/repository/NodeRepository.kt:
--------------------------------------------------------------------------------
1 | package io.github.v2compose.repository
2 |
3 | import androidx.paging.PagingData
4 | import io.github.v2compose.network.bean.*
5 | import kotlinx.coroutines.flow.Flow
6 |
7 | interface NodeRepository {
8 |
9 | suspend fun getNodes(): NodesInfo
10 |
11 | suspend fun getAllNodes(): List
12 |
13 | val nodesNavInfo: Flow
14 | suspend fun getNodesNavInfo(): NodesNavInfo
15 |
16 | suspend fun getNodeInfo(nodeName: String): NodeInfo
17 | fun getNodeTopicInfo(nodeName: String): Flow>
18 |
19 | suspend fun doNodeAction(nodeName: String, actionUrl: String): NodeTopicInfo
20 |
21 | }
--------------------------------------------------------------------------------
/app/src/main/java/io/github/v2compose/repository/UserRepository.kt:
--------------------------------------------------------------------------------
1 | package io.github.v2compose.repository
2 |
3 | import androidx.paging.PagingData
4 | import io.github.v2compose.network.bean.UserPageInfo
5 | import io.github.v2compose.network.bean.UserReplies
6 | import io.github.v2compose.network.bean.UserTopics
7 | import kotlinx.coroutines.flow.Flow
8 |
9 | interface UserRepository {
10 |
11 | suspend fun getUserPageInfo(userName: String): UserPageInfo
12 |
13 | fun getUserTopics(userName: String): Flow>
14 |
15 | fun getUserReplies(userName: String): Flow>
16 |
17 | suspend fun doUserAction(userName: String, url:String): UserPageInfo
18 |
19 | }
--------------------------------------------------------------------------------
/app/src/main/res/drawable/ic_launcher_foreground.xml:
--------------------------------------------------------------------------------
1 |
7 |
11 |
14 |
17 |
18 |
19 |
--------------------------------------------------------------------------------
/app/src/main/res/xml/network_security_config.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | v2ex.com
5 | v2ex.co
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
--------------------------------------------------------------------------------
/app/src/main/java/io/github/v2compose/util/L.java:
--------------------------------------------------------------------------------
1 | package io.github.v2compose.util;
2 |
3 | import com.orhanobut.logger.Logger;
4 |
5 | /**
6 | * Logger 封装
7 | */
8 |
9 | public class L {
10 |
11 | public static void d(String msg) {
12 | Logger.d(msg);
13 | }
14 |
15 | public static void e(String msg) {
16 | Logger.e(msg);
17 | }
18 |
19 | public static void w(String msg) {
20 | Logger.w(msg);
21 | }
22 |
23 | public static void i(String msg) {
24 | Logger.i(msg);
25 | }
26 |
27 | public static void v(String msg) {
28 | Logger.v(msg);
29 | }
30 |
31 | public static void json(String msg) {
32 | Logger.json(msg);
33 | }
34 |
35 | }
36 |
--------------------------------------------------------------------------------
/app/src/main/java/io/github/v2compose/ui/gallery/composables/PopupImage.kt:
--------------------------------------------------------------------------------
1 | package io.github.v2compose.ui.gallery.composables
2 |
3 | import androidx.compose.runtime.Composable
4 | import androidx.compose.ui.window.Popup
5 | import androidx.compose.ui.window.PopupProperties
6 | import io.github.v2compose.ui.common.GalleryImage
7 |
8 | @Composable
9 | fun PopupImage(imageUrl: String, onDismiss: () -> Unit) {
10 |
11 | Popup(
12 | onDismissRequest = onDismiss,
13 | properties = PopupProperties(
14 | focusable = true,
15 | dismissOnBackPress = true,
16 | dismissOnClickOutside = true,
17 | )
18 | ) {
19 | GalleryImage(imageUrl = imageUrl, onBackgroundClick = onDismiss)
20 | }
21 | }
--------------------------------------------------------------------------------
/app/src/main/java/io/github/v2compose/ui/gallery/GalleryViewModel.kt:
--------------------------------------------------------------------------------
1 | package io.github.v2compose.ui.gallery
2 |
3 | import android.util.Log
4 | import androidx.lifecycle.SavedStateHandle
5 | import androidx.lifecycle.ViewModel
6 | import dagger.hilt.android.lifecycle.HiltViewModel
7 | import io.github.v2compose.core.StringDecoder
8 | import javax.inject.Inject
9 |
10 | private const val TAG = "GalleryViewModel"
11 |
12 | @HiltViewModel
13 | class GalleryViewModel @Inject constructor(
14 | savedStateHandle: SavedStateHandle,
15 | stringDecoder: StringDecoder
16 | ) : ViewModel() {
17 |
18 | val screenArgs = GalleryScreenArgs(savedStateHandle, stringDecoder)
19 |
20 | init {
21 | Log.d(TAG, "args = $screenArgs")
22 | }
23 |
24 | }
--------------------------------------------------------------------------------
/app/src/main/java/io/github/v2compose/ui/HandleSnackbarMessage.kt:
--------------------------------------------------------------------------------
1 | package io.github.v2compose.ui
2 |
3 | import androidx.compose.runtime.Composable
4 | import androidx.compose.runtime.LaunchedEffect
5 | import androidx.compose.runtime.getValue
6 | import androidx.lifecycle.compose.collectAsStateWithLifecycle
7 |
8 | @Composable
9 | fun HandleSnackbarMessage(
10 | viewModel: BaseViewModel,
11 | screenState: BaseScreenState
12 | ) {
13 | val snackbarMessage by viewModel.snackbarMessage.collectAsStateWithLifecycle()
14 | if (!snackbarMessage.isNullOrEmpty()) {
15 | LaunchedEffect(snackbarMessage) {
16 | screenState.showMessage(snackbarMessage!!)
17 | viewModel.updateSnackbarMessage(null)
18 | }
19 | }
20 | }
--------------------------------------------------------------------------------
/app/src/main/java/io/github/v2compose/util/InetValidator.kt:
--------------------------------------------------------------------------------
1 | package io.github.v2compose.util
2 |
3 | import java.util.regex.Pattern
4 |
5 | object InetValidator {
6 |
7 | private const val hostOrIpRegex = "^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\$|^(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\\-]*[a-zA-Z0-9])\\.)+([A-Za-z]|[A-Za-z][A-Za-z0-9\\-]*[A-Za-z0-9])\$"
8 |
9 | private val hostOrIpPattern: Pattern = Pattern.compile(hostOrIpRegex)
10 |
11 | fun isValidHostOrIp(hostOrIp:String):Boolean{
12 | val matcher = hostOrIpPattern.matcher(hostOrIp)
13 | return matcher.matches()
14 | }
15 |
16 | fun isValidInetPort(inetPort: Int): Boolean {
17 | return inetPort in 0..65535
18 | }
19 | }
--------------------------------------------------------------------------------
/app/src/main/java/io/github/v2compose/core/extension/StringList.kt:
--------------------------------------------------------------------------------
1 | package io.github.v2compose.core.extension
2 |
3 | import com.squareup.moshi.JsonAdapter
4 | import com.squareup.moshi.Moshi
5 | import com.squareup.moshi.Types
6 |
7 |
8 | fun List.toJson(moshi: Moshi): String {
9 | val stringListType = Types.newParameterizedType(List::class.java, String::class.java)
10 | val adapter: JsonAdapter> = moshi.adapter(stringListType)
11 | return adapter.toJson(this)
12 | }
13 |
14 | fun String.toStringList(moshi: Moshi): List? {
15 | val stringListType = Types.newParameterizedType(List::class.java, String::class.java)
16 | val adapter: JsonAdapter> = moshi.adapter(stringListType)
17 | return adapter.fromJson(this)
18 | }
--------------------------------------------------------------------------------
/app/src/main/java/io/github/v2compose/util/Check.java:
--------------------------------------------------------------------------------
1 | package io.github.v2compose.util;
2 |
3 | import java.util.List;
4 |
5 | /**
6 | * Created by ghui on 30/10/2017.
7 | */
8 |
9 | public class Check {
10 | public static boolean isEmpty(CharSequence text) {
11 | return text == null || text.length() <= 0;
12 | }
13 |
14 | public static boolean notEmpty(CharSequence... texts) {
15 | for (CharSequence text : texts) {
16 | if (isEmpty(text)) return false;
17 | }
18 | return true;
19 | }
20 |
21 | public static boolean isEmpty(List list) {
22 | return list == null || list.isEmpty();
23 | }
24 |
25 | public static boolean notEmpty(List list) {
26 | return !isEmpty(list);
27 | }
28 | }
29 |
--------------------------------------------------------------------------------
/.idea/gradle.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
19 |
20 |
--------------------------------------------------------------------------------
/app/src/androidTest/java/io/github/v2compose/ExampleInstrumentedTest.kt:
--------------------------------------------------------------------------------
1 | package io.github.v2compose
2 |
3 | import androidx.test.platform.app.InstrumentationRegistry
4 | import androidx.test.ext.junit.runners.AndroidJUnit4
5 |
6 | import org.junit.Test
7 | import org.junit.runner.RunWith
8 |
9 | import org.junit.Assert.*
10 |
11 | /**
12 | * Instrumented test, which will execute on an Android device.
13 | *
14 | * See [testing documentation](http://d.android.com/tools/testing).
15 | */
16 | @RunWith(AndroidJUnit4::class)
17 | class ExampleInstrumentedTest {
18 | @Test
19 | fun useAppContext() {
20 | // Context of the app under test.
21 | val appContext = InstrumentationRegistry.getInstrumentation().targetContext
22 | assertEquals("io.github.v2compose", appContext.packageName)
23 | }
24 | }
--------------------------------------------------------------------------------
/app/src/main/java/io/github/v2compose/ui/login/twostep/TwoStepLoginNavigation.kt:
--------------------------------------------------------------------------------
1 | package io.github.v2compose.ui.login.twostep
2 |
3 | import androidx.compose.animation.ExperimentalAnimationApi
4 | import androidx.navigation.NavController
5 | import androidx.navigation.NavGraphBuilder
6 | import com.google.accompanist.navigation.animation.composable
7 |
8 | const val twoStepLoginNavigationRoute = "/2fa"
9 |
10 | fun NavController.navigateToTwoStepLogin() {
11 | navigate(twoStepLoginNavigationRoute)
12 | }
13 |
14 | @OptIn(ExperimentalAnimationApi::class)
15 | fun NavGraphBuilder.twoStepLoginScreen(
16 | onCloseClick: () -> Unit,
17 | ) {
18 | composable(
19 | twoStepLoginNavigationRoute,
20 | ) {
21 | TwoStepLoginScreenRoute(
22 | onCloseClick = onCloseClick,
23 | )
24 | }
25 | }
--------------------------------------------------------------------------------
/app/src/main/java/io/github/v2compose/ui/main/mine/nodes/MyNodesNavigation.kt:
--------------------------------------------------------------------------------
1 | package io.github.v2compose.ui.main.mine.nodes
2 |
3 | import androidx.compose.animation.ExperimentalAnimationApi
4 | import androidx.navigation.NavController
5 | import androidx.navigation.NavGraphBuilder
6 | import com.google.accompanist.navigation.animation.composable
7 | import io.github.v2compose.network.bean.MyNodesInfo
8 |
9 | const val myNodesRoute = "/my/nodes"
10 |
11 | fun NavController.navigateToMyNodes() {
12 | navigate(myNodesRoute)
13 | }
14 |
15 | @OptIn(ExperimentalAnimationApi::class)
16 | fun NavGraphBuilder.myNodesScreen(
17 | onBackClick: () -> Unit,
18 | onNodeClick: (MyNodesInfo.Item) -> Unit
19 | ) {
20 | composable(myNodesRoute) {
21 | MyNodesScreenRoute(onBackClick = onBackClick, onNodeClick = onNodeClick)
22 | }
23 | }
--------------------------------------------------------------------------------
/htmlText/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.
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
--------------------------------------------------------------------------------
/htmlText/src/androidTest/java/io/github/cooaer/htmlview/ExampleInstrumentedTest.kt:
--------------------------------------------------------------------------------
1 | package io.github.cooaer.htmltext
2 |
3 | import androidx.test.platform.app.InstrumentationRegistry
4 | import androidx.test.ext.junit.runners.AndroidJUnit4
5 |
6 | import org.junit.Test
7 | import org.junit.runner.RunWith
8 |
9 | import org.junit.Assert.*
10 |
11 | /**
12 | * Instrumented test, which will execute on an Android device.
13 | *
14 | * See [testing documentation](http://d.android.com/tools/testing).
15 | */
16 | @RunWith(AndroidJUnit4::class)
17 | class ExampleInstrumentedTest {
18 | @Test
19 | fun useAppContext() {
20 | // Context of the app under test.
21 | val appContext = InstrumentationRegistry.getInstrumentation().targetContext
22 | assertEquals("io.github.cooaer.htmltext.test", appContext.packageName)
23 | }
24 | }
--------------------------------------------------------------------------------
/app/src/main/java/io/github/v2compose/MainActivity.kt:
--------------------------------------------------------------------------------
1 | package io.github.v2compose
2 |
3 | import android.os.Bundle
4 | import androidx.activity.ComponentActivity
5 | import androidx.activity.compose.setContent
6 | import androidx.compose.runtime.Composable
7 | import androidx.compose.ui.tooling.preview.Preview
8 | import androidx.core.view.WindowCompat
9 | import dagger.hilt.android.AndroidEntryPoint
10 |
11 | @AndroidEntryPoint
12 | class MainActivity : ComponentActivity() {
13 | override fun onCreate(savedInstanceState: Bundle?) {
14 | super.onCreate(savedInstanceState)
15 |
16 | WindowCompat.setDecorFitsSystemWindows(window, false)
17 |
18 | setContent {
19 | V2App()
20 | }
21 | }
22 | }
23 |
24 |
25 | @Preview(showBackground = true, widthDp = 440, heightDp = 880)
26 | @Composable
27 | fun DefaultPreview() {
28 | V2App()
29 | }
--------------------------------------------------------------------------------
/app/src/main/java/io/github/v2compose/ui/BaseScreenState.kt:
--------------------------------------------------------------------------------
1 | package io.github.v2compose.ui
2 |
3 | import android.content.Context
4 | import androidx.annotation.StringRes
5 | import androidx.compose.material3.SnackbarDuration
6 | import androidx.compose.material3.SnackbarHostState
7 | import kotlinx.coroutines.CoroutineScope
8 | import kotlinx.coroutines.launch
9 |
10 | open class BaseScreenState(
11 | protected val context: Context,
12 | protected val coroutineScope: CoroutineScope,
13 | val snackbarHostState: SnackbarHostState,
14 | ) {
15 |
16 | fun showMessage(@StringRes messageResId: Int) {
17 | showMessage(context.getString(messageResId))
18 | }
19 |
20 | fun showMessage(message: String) {
21 | coroutineScope.launch {
22 | snackbarHostState.showSnackbar(message = message, duration = SnackbarDuration.Short)
23 | }
24 | }
25 |
26 | }
--------------------------------------------------------------------------------
/app/src/main/java/io/github/v2compose/bean/ProxyInfo.kt:
--------------------------------------------------------------------------------
1 | package io.github.v2compose.bean
2 |
3 | import com.squareup.moshi.JsonClass
4 | import com.squareup.moshi.Moshi
5 | import com.squareup.moshi.adapter
6 |
7 | @JsonClass(generateAdapter = true)
8 | data class ProxyInfo(
9 | val type: ProxyType = ProxyType.Direct,
10 | val address: String = "",
11 | val port: Int = 0,
12 | ) {
13 | companion object {
14 | val Default = ProxyInfo()
15 |
16 | @OptIn(ExperimentalStdlibApi::class)
17 | fun fromJson(moshi: Moshi, json: String): ProxyInfo {
18 | return moshi.adapter().fromJson(json) ?: Default
19 | }
20 | }
21 |
22 | @OptIn(ExperimentalStdlibApi::class)
23 | fun toJson(moshi: Moshi): String {
24 | return moshi.adapter().toJson(this)
25 | }
26 | }
27 |
28 | enum class ProxyType { System, Direct, Http, Socks }
--------------------------------------------------------------------------------
/app/src/main/java/io/github/v2compose/ui/settings/SettingsNavigation.kt:
--------------------------------------------------------------------------------
1 | package io.github.v2compose.ui.settings
2 |
3 | import androidx.compose.animation.ExperimentalAnimationApi
4 | import androidx.navigation.NavController
5 | import androidx.navigation.NavGraphBuilder
6 | import com.google.accompanist.navigation.animation.composable
7 |
8 | const val settingsScreenNavigationRoute = "/settings"
9 |
10 | fun NavController.navigateToSettings() {
11 | navigate(settingsScreenNavigationRoute)
12 | }
13 |
14 | @OptIn(ExperimentalAnimationApi::class)
15 | fun NavGraphBuilder.settingsScreen(
16 | onBackClick: () -> Unit,
17 | openUri: (String) -> Unit,
18 | onLogoutSuccess: () -> Unit
19 | ) {
20 | composable(route = settingsScreenNavigationRoute) {
21 | SettingsScreenRoute(
22 | onBackClick = onBackClick,
23 | openUri = openUri,
24 | onLogoutSuccess = onLogoutSuccess
25 | )
26 | }
27 | }
--------------------------------------------------------------------------------
/app/src/main/java/io/github/v2compose/ui/common/LazyPagingItemsWorkaround.kt:
--------------------------------------------------------------------------------
1 | package io.github.v2compose.ui.common
2 |
3 | import androidx.compose.foundation.lazy.LazyListState
4 | import androidx.compose.runtime.Composable
5 | import androidx.compose.runtime.remember
6 | import androidx.paging.compose.LazyPagingItems
7 |
8 | @Composable
9 | fun LazyPagingItems.rememberLazyListState(): LazyListState {
10 | // After recreation, LazyPagingItems first return 0 items, then the cached items.
11 | // This behavior/issue is resetting the LazyListState scroll position.
12 | // Below is a workaround. More info: https://issuetracker.google.com/issues/177245496.
13 | return when (itemCount) {
14 | // Return a different LazyListState instance.
15 | 0 -> remember(this) { LazyListState(0, 0) }
16 | // Return rememberLazyListState (normal case).
17 | else -> androidx.compose.foundation.lazy.rememberLazyListState()
18 | }
19 | }
--------------------------------------------------------------------------------
/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.
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 | ## Gson
24 | -keep class io.github.v2compose.bean.** { ; }
25 | -keep class io.github.v2compose.network.bean.** { ; }
--------------------------------------------------------------------------------
/app/src/main/java/io/github/v2compose/ui/BaseViewModel.kt:
--------------------------------------------------------------------------------
1 | package io.github.v2compose.ui
2 |
3 | import android.app.Application
4 | import android.content.Context
5 | import androidx.annotation.StringRes
6 | import androidx.lifecycle.AndroidViewModel
7 | import io.github.v2compose.App
8 | import kotlinx.coroutines.flow.MutableStateFlow
9 | import kotlinx.coroutines.flow.StateFlow
10 |
11 | open class BaseViewModel(application: Application) : AndroidViewModel(application) {
12 |
13 | val context: Context
14 | get() = getApplication().applicationContext
15 |
16 | private val _snackbarMessage = MutableStateFlow(null)
17 | val snackbarMessage: StateFlow = _snackbarMessage
18 |
19 | suspend fun updateSnackbarMessage(value: String?) {
20 | _snackbarMessage.emit(value)
21 | }
22 |
23 | suspend fun updateSnackbarMessage(@StringRes valueResId: Int) {
24 | _snackbarMessage.emit(context.getString(valueResId))
25 | }
26 |
27 |
28 | }
--------------------------------------------------------------------------------
/app/src/main/java/io/github/v2compose/ui/login/google/GoogleLoginViewModel.kt:
--------------------------------------------------------------------------------
1 | package io.github.v2compose.ui.login.google
2 |
3 | import androidx.lifecycle.ViewModel
4 | import androidx.lifecycle.viewModelScope
5 | import dagger.hilt.android.lifecycle.HiltViewModel
6 | import io.github.v2compose.bean.Account
7 | import io.github.v2compose.repository.AccountRepository
8 | import kotlinx.coroutines.flow.SharingStarted
9 | import kotlinx.coroutines.flow.stateIn
10 | import javax.inject.Inject
11 |
12 | @HiltViewModel
13 | class GoogleLoginViewModel @Inject constructor(
14 | private val accountRepository: AccountRepository,
15 | ) : ViewModel() {
16 |
17 | val account = accountRepository.account
18 | .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), Account.Empty)
19 |
20 | suspend fun fetchUserInfo() {
21 | try {
22 | accountRepository.fetchUserInfo()
23 | } catch (e: Exception) {
24 | e.printStackTrace()
25 | }
26 | }
27 |
28 | }
--------------------------------------------------------------------------------
/app/src/main/java/io/github/v2compose/network/NetConstants.kt:
--------------------------------------------------------------------------------
1 | package io.github.v2compose.network
2 |
3 | import android.os.Build
4 |
5 | /**
6 | * Created by ghui on 25/03/2017.
7 | */
8 | object NetConstants {
9 | const val HTTPS_SCHEME = "https:"
10 | const val HTTP_SCHEME = "http:"
11 | const val BASE_URL = "$HTTPS_SCHEME//www.v2ex.com"
12 |
13 | val wapUserAgent =
14 | "Mozilla/5.0 (Linux; Android 10; V2compose Build/${Build.MODEL}) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/108.0.0.0 Mobile Safari/537.36"
15 |
16 | const val webUserAgent =
17 | "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_4; V2er) " + "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/60.0.3112.90 Safari/537.36"
18 | const val keyUserAgent = "user-agent"
19 |
20 | private const val defaultSystemUserAgent =
21 | "Dalvik/2.1.0 (Linux; U; Android 12; Mi 10 Build/SKQ1.211006.001)"
22 |
23 | val systemUserAgent: String = System.getProperty("http.agent") ?: defaultSystemUserAgent
24 | }
--------------------------------------------------------------------------------
/app/src/main/java/io/github/v2compose/ui/main/home/recent/RecentViewModel.kt:
--------------------------------------------------------------------------------
1 | package io.github.v2compose.ui.main.home.recent
2 |
3 | import androidx.lifecycle.ViewModel
4 | import androidx.lifecycle.viewModelScope
5 | import androidx.paging.cachedIn
6 | import dagger.hilt.android.lifecycle.HiltViewModel
7 | import io.github.v2compose.datasource.AppPreferences
8 | import io.github.v2compose.repository.NewsRepository
9 | import kotlinx.coroutines.flow.SharingStarted
10 | import kotlinx.coroutines.flow.map
11 | import kotlinx.coroutines.flow.stateIn
12 | import javax.inject.Inject
13 |
14 | @HiltViewModel
15 | class RecentViewModel @Inject constructor(
16 | newsRepository: NewsRepository,
17 | appPreferences: AppPreferences
18 | ) : ViewModel() {
19 |
20 | val recentTopics = newsRepository.recentTopics.cachedIn(viewModelScope)
21 | val topicTitleOverview = appPreferences.appSettings.map { it.topicTitleOverview }
22 | .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), true)
23 |
24 | }
--------------------------------------------------------------------------------
/app/src/main/java/io/github/v2compose/util/Logf.kt:
--------------------------------------------------------------------------------
1 | package io.github.v2compose.util
2 |
3 | import io.github.v2compose.App
4 | import java.io.BufferedWriter
5 | import java.io.File
6 | import java.io.FileWriter
7 | import java.io.IOException
8 |
9 | object Logf {
10 | fun appendLog(text: String?) {
11 | val path = App.instance.externalCacheDir?.absolutePath ?: return
12 | val logFile = File("$path/log.file")
13 | if (!logFile.exists()) {
14 | try {
15 | logFile.createNewFile()
16 | } catch (e: IOException) {
17 | e.printStackTrace()
18 | }
19 | }
20 | try {
21 | //BufferedWriter for performance, true to set append to file flag
22 | val buf = BufferedWriter(FileWriter(logFile, true))
23 | buf.append(text)
24 | buf.newLine()
25 | buf.close()
26 | } catch (e: IOException) {
27 | e.printStackTrace()
28 | }
29 | }
30 | }
--------------------------------------------------------------------------------
/app/src/main/java/io/github/v2compose/network/bean/ReplyTopicResultInfo.java:
--------------------------------------------------------------------------------
1 | package io.github.v2compose.network.bean;
2 |
3 | import me.ghui.fruit.Attrs;
4 | import me.ghui.fruit.annotations.Pick;
5 |
6 | @Pick(value = "div#Wrapper")
7 | public class ReplyTopicResultInfo extends BaseInfo {
8 | @Pick(value = "input[name=once]", attr = "value")
9 | private String once;
10 | @Pick(value = "div.problem", attr = Attrs.HTML)
11 | private String problem;
12 |
13 | public String getOnce() {
14 | return once;
15 | }
16 |
17 | public String getProblem() {
18 | return problem != null ? problem : "";
19 | }
20 |
21 | @Override
22 | public boolean isValid() {
23 | return once != null && !once.isEmpty();
24 | }
25 |
26 | @Override
27 | public String toString() {
28 | return "ReplyTopicResultInfo{" +
29 | "once='" + once + '\'' +
30 | ", problem='" + problem + '\'' +
31 | '}';
32 | }
33 | }
34 |
--------------------------------------------------------------------------------
/app/src/main/java/io/github/v2compose/bean/DraftTopic.kt:
--------------------------------------------------------------------------------
1 | package io.github.v2compose.bean
2 |
3 | import com.squareup.moshi.JsonClass
4 | import com.squareup.moshi.Moshi
5 | import com.squareup.moshi.adapter
6 | import io.github.v2compose.network.bean.TopicNode
7 |
8 | @JsonClass(generateAdapter = true)
9 | data class DraftTopic(
10 | val title: String = "",
11 | val content: String = "",
12 | val contentFormat: ContentFormat = ContentFormat.Original,
13 | val node: TopicNode? = null,
14 | ) {
15 |
16 | companion object {
17 |
18 | val Empty = DraftTopic()
19 |
20 | @OptIn(ExperimentalStdlibApi::class)
21 | fun fromJson(moshi: Moshi, json: String): DraftTopic {
22 | return moshi.adapter().fromJson(json) ?: Empty
23 | }
24 | }
25 |
26 |
27 | @OptIn(ExperimentalStdlibApi::class)
28 | fun toJson(moshi: Moshi): String {
29 | return moshi.adapter().toJson(this)
30 | }
31 | }
32 |
33 |
34 | enum class ContentFormat {
35 | Original, Markdown
36 | }
--------------------------------------------------------------------------------
/app/src/main/java/io/github/v2compose/core/extension/AppVersion.kt:
--------------------------------------------------------------------------------
1 | package io.github.v2compose.core.extension
2 |
3 | //https://semver.org/ eg: v0.0.1 、 1.0.0
4 | fun String.toAppVersion(): Triple {
5 | val parts = (if (this.startsWith('v')) substring(startIndex = 1) else this).split('.')
6 | if (parts.size >= 3) {
7 | return Triple(
8 | first = parts[0].toIntOrNull() ?: 0,
9 | second = parts[1].toIntOrNull() ?: 0,
10 | third = parts[2].toIntOrNull() ?: 0
11 | )
12 | }
13 | return Triple(0, 0, 0)
14 | }
15 |
16 | fun Triple.newerThan(other: Triple): Boolean {
17 | if (this.first > other.first) {
18 | return true
19 | } else if (this.first < other.first) {
20 | return false
21 | }
22 | if (this.second > other.second) {
23 | return true
24 | } else if (this.second < other.second) {
25 | return false
26 | }
27 | if (this.third > other.third) {
28 | return true
29 | }
30 | return false
31 | }
--------------------------------------------------------------------------------
/app/src/main/java/io/github/v2compose/ui/main/home/HomeViewModel.kt:
--------------------------------------------------------------------------------
1 | package io.github.v2compose.ui.main.home
2 |
3 | import androidx.lifecycle.ViewModel
4 | import dagger.hilt.android.lifecycle.HiltViewModel
5 | import javax.inject.Inject
6 |
7 | @HiltViewModel
8 | class HomeViewModel @Inject constructor() : ViewModel() {
9 | private val TAB_NAMES =
10 | arrayOf("全部", "最热", "最近", "技术", "创意", "好玩", "Apple", "酷工作", "交易", "城市", "问与答", "R2", "节点", "关注")
11 | private val TAB_VALUES = arrayOf(
12 | "all",
13 | "hot",
14 | "recent",
15 | "tech",
16 | "creative",
17 | "play",
18 | "apple",
19 | "jobs",
20 | "deals",
21 | "city",
22 | "qna",
23 | "r2",
24 | "nodes",
25 | "members"
26 | )
27 | val newsTabInfos =
28 | TAB_NAMES.mapIndexed { index, title -> NewsTabInfo(title, TAB_VALUES[index]) }
29 | }
30 |
31 | data class NewsTabInfo(val name: String, val value: String){
32 | companion object{
33 | const val recent = "recent"
34 | }
35 | }
--------------------------------------------------------------------------------
/app/src/main/java/io/github/v2compose/ui/main/mine/topics/MyTopicsNavigation.kt:
--------------------------------------------------------------------------------
1 | package io.github.v2compose.ui.main.mine.topics
2 |
3 | import androidx.compose.animation.ExperimentalAnimationApi
4 | import androidx.navigation.NavController
5 | import androidx.navigation.NavGraphBuilder
6 | import com.google.accompanist.navigation.animation.composable
7 | import io.github.v2compose.network.bean.MyTopicsInfo
8 |
9 | const val myTopicsRoute = "/my/topics"
10 |
11 | fun NavController.navigateToMyTopics() {
12 | navigate(myTopicsRoute)
13 | }
14 |
15 | @OptIn(ExperimentalAnimationApi::class)
16 | fun NavGraphBuilder.myTopicsScreen(
17 | onBackClick: () -> Unit,
18 | onTopicClick: (MyTopicsInfo.Item) -> Unit,
19 | onNodeClick: (String, String) -> Unit,
20 | onUserAvatarClick: (String, String) -> Unit,
21 | ) {
22 | composable(myTopicsRoute) {
23 | MyTopicsScreenRoute(
24 | onBackClick = onBackClick,
25 | onTopicClick = onTopicClick,
26 | onNodeClick = onNodeClick,
27 | onUserAvatarClick = onUserAvatarClick,
28 | )
29 | }
30 | }
--------------------------------------------------------------------------------
/app/src/main/java/io/github/v2compose/ui/common/NodeComposables.kt:
--------------------------------------------------------------------------------
1 | package io.github.v2compose.ui.common
2 |
3 | import androidx.compose.foundation.background
4 | import androidx.compose.foundation.clickable
5 | import androidx.compose.foundation.layout.padding
6 | import androidx.compose.material3.MaterialTheme
7 | import androidx.compose.material3.Text
8 | import androidx.compose.runtime.Composable
9 | import androidx.compose.ui.Modifier
10 | import androidx.compose.ui.unit.dp
11 |
12 | @Composable
13 | fun NodeTag(
14 | nodeName: String,
15 | nodeTitle: String,
16 | onItemClick: ((String, String) -> Unit)? = null
17 | ) {
18 | Text(
19 | nodeTitle,
20 | modifier = Modifier
21 | .clickable(enabled = onItemClick != null) {
22 | onItemClick?.invoke(nodeName, nodeTitle)
23 | }
24 | .background(MaterialTheme.colorScheme.surfaceVariant)
25 | .padding(horizontal = 8.dp, vertical = 4.dp),
26 | style = MaterialTheme.typography.labelLarge,
27 | color = MaterialTheme.colorScheme.onSurfaceVariant
28 | )
29 | }
30 |
--------------------------------------------------------------------------------
/app/src/main/java/io/github/v2compose/ui/common/HtmlAlertDialog.kt:
--------------------------------------------------------------------------------
1 | package io.github.v2compose.ui.common
2 |
3 | import androidx.compose.material3.AlertDialog
4 | import androidx.compose.material3.Text
5 | import androidx.compose.material3.TextButton
6 | import androidx.compose.runtime.*
7 | import androidx.compose.ui.res.stringResource
8 | import io.github.v2compose.R
9 |
10 |
11 | @Composable
12 | fun HtmlAlertDialog(
13 | title: String? = null,
14 | content: String,
15 | onUriClick: ((uri: String) -> Unit)? = null
16 | ) {
17 | var showDialog by remember(content) { mutableStateOf(content.isNotEmpty()) }
18 |
19 | if (showDialog) {
20 | AlertDialog(onDismissRequest = { showDialog = false },
21 | title = { title?.let { Text(title) } },
22 | text = {
23 | HtmlContent(content = content, onUriClick = onUriClick)
24 | },
25 | confirmButton = {
26 | TextButton(onClick = { showDialog = false }) {
27 | Text(stringResource(id = R.string.ok))
28 | }
29 | })
30 | }
31 | }
32 |
--------------------------------------------------------------------------------
/app/src/main/java/io/github/v2compose/V2AppViewModel.kt:
--------------------------------------------------------------------------------
1 | package io.github.v2compose
2 |
3 | import androidx.lifecycle.ViewModel
4 | import androidx.lifecycle.viewModelScope
5 | import dagger.hilt.android.lifecycle.HiltViewModel
6 | import io.github.v2compose.bean.Account
7 | import io.github.v2compose.datasource.AppPreferences
8 | import io.github.v2compose.bean.AppSettings
9 | import io.github.v2compose.repository.AccountRepository
10 | import kotlinx.coroutines.flow.SharingStarted
11 | import kotlinx.coroutines.flow.stateIn
12 | import javax.inject.Inject
13 |
14 | @HiltViewModel
15 | class V2AppViewModel @Inject constructor(
16 | private val appPreferences: AppPreferences,
17 | private val accountRepository: AccountRepository,
18 | ) : ViewModel() {
19 |
20 | val appSettings = appPreferences.appSettings.stateIn(
21 | viewModelScope,
22 | SharingStarted.WhileSubscribed(),
23 | AppSettings.Default
24 | )
25 |
26 | val account = accountRepository.account.stateIn(
27 | viewModelScope,
28 | SharingStarted.WhileSubscribed(),
29 | Account.Empty
30 | )
31 |
32 | }
--------------------------------------------------------------------------------
/app/src/main/java/io/github/v2compose/ui/main/mine/topics/MyTopicsViewModel.kt:
--------------------------------------------------------------------------------
1 | package io.github.v2compose.ui.main.mine.topics
2 |
3 | import androidx.lifecycle.ViewModel
4 | import androidx.lifecycle.viewModelScope
5 | import androidx.paging.cachedIn
6 | import dagger.hilt.android.lifecycle.HiltViewModel
7 | import io.github.v2compose.datasource.AppPreferences
8 | import io.github.v2compose.repository.AccountRepository
9 | import kotlinx.coroutines.flow.SharingStarted
10 | import kotlinx.coroutines.flow.StateFlow
11 | import kotlinx.coroutines.flow.map
12 | import kotlinx.coroutines.flow.stateIn
13 | import javax.inject.Inject
14 |
15 | @HiltViewModel
16 | class MyTopicsViewModel @Inject constructor(
17 | private val accountRepository: AccountRepository,
18 | private val appPreferences: AppPreferences,
19 | ) : ViewModel() {
20 |
21 | val topicTitleOverview: StateFlow =
22 | appPreferences.appSettings.map { it.topicTitleOverview }
23 | .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), true)
24 |
25 | val myTopics = accountRepository.myTopics.cachedIn(viewModelScope)
26 |
27 | }
--------------------------------------------------------------------------------
/app/src/main/java/io/github/v2compose/ui/main/mine/following/MyFollowingNavigation.kt:
--------------------------------------------------------------------------------
1 | package io.github.v2compose.ui.main.mine.following
2 |
3 | import androidx.compose.animation.ExperimentalAnimationApi
4 | import androidx.navigation.NavController
5 | import androidx.navigation.NavGraphBuilder
6 | import com.google.accompanist.navigation.animation.composable
7 | import io.github.v2compose.network.bean.MyFollowingInfo
8 |
9 | const val myFollowingRoute = "/my/following"
10 |
11 | fun NavController.navigateToMyFollowing() {
12 | navigate(myFollowingRoute)
13 | }
14 |
15 | @OptIn(ExperimentalAnimationApi::class)
16 | fun NavGraphBuilder.myFollowingScreen(
17 | onBackClick: () -> Unit,
18 | onTopicClick: (MyFollowingInfo.Item) -> Unit,
19 | onNodeClick: (String, String) -> Unit,
20 | onUserAvatarClick: (String, String) -> Unit,
21 | ) {
22 | composable(myFollowingRoute) {
23 | MyFollowingScreenRoute(
24 | onBackClick = onBackClick,
25 | onTopicClick = onTopicClick,
26 | onNodeClick = onNodeClick,
27 | onUserAvatarClick = onUserAvatarClick,
28 | )
29 | }
30 | }
--------------------------------------------------------------------------------
/app/src/main/java/io/github/v2compose/network/bean/Node.kt:
--------------------------------------------------------------------------------
1 | package io.github.v2compose.network.bean
2 |
3 | import com.squareup.moshi.Json
4 | import com.squareup.moshi.JsonClass
5 |
6 | @JsonClass(generateAdapter = true)
7 | data class Node(
8 | val id: Int = 0,
9 | val name: String = "",
10 | val title: String = "",
11 | val url: String = "",
12 | val topics: Int = 0,
13 | val stars: Int = 0,
14 | @Json(name = "avatar_large")
15 | val avatarLarge: String = "",
16 | @Json(name = "avatar_normal")
17 | val avatarNormal: String = "",
18 | @Json(name = "avatar_mini")
19 | val avatarMini: String = "",
20 | @Json(name = "title_alternative")
21 | val titleAlternative: String = "",
22 | @Json(name = "header")
23 | val header: String = "",
24 | @Json(name = "footer")
25 | val footer: String = "",
26 | val root: Boolean = false,
27 | @Json(name = "parent_node_name")
28 | val parentNodeName: String = "",
29 | val aliases: List = listOf(),
30 | ) {
31 | val avatar: String
32 | get() = avatarLarge.ifEmpty { avatarNormal.ifEmpty { avatarMini } }
33 | }
34 |
--------------------------------------------------------------------------------
/app/src/main/java/io/github/v2compose/ui/main/mine/following/MyFollowingViewModel.kt:
--------------------------------------------------------------------------------
1 | package io.github.v2compose.ui.main.mine.following
2 |
3 | import androidx.lifecycle.ViewModel
4 | import androidx.lifecycle.viewModelScope
5 | import androidx.paging.cachedIn
6 | import dagger.hilt.android.lifecycle.HiltViewModel
7 | import io.github.v2compose.datasource.AppPreferences
8 | import io.github.v2compose.repository.AccountRepository
9 | import kotlinx.coroutines.flow.SharingStarted
10 | import kotlinx.coroutines.flow.StateFlow
11 | import kotlinx.coroutines.flow.map
12 | import kotlinx.coroutines.flow.stateIn
13 | import javax.inject.Inject
14 |
15 | @HiltViewModel
16 | class MyFollowingViewModel @Inject constructor(
17 | private val accountRepository: AccountRepository,
18 | private val appPreferences: AppPreferences,
19 | ) : ViewModel() {
20 |
21 | val topicTitleOverview: StateFlow =
22 | appPreferences.appSettings.map { it.topicTitleOverview }
23 | .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), true)
24 |
25 | val myFollowing = accountRepository.myFollowing.cachedIn(viewModelScope)
26 |
27 | }
--------------------------------------------------------------------------------
/app/src/main/java/io/github/v2compose/core/LinkHandler.kt:
--------------------------------------------------------------------------------
1 | package io.github.v2compose.core
2 |
3 | import android.content.Context
4 | import android.content.Intent
5 | import androidx.browser.customtabs.CustomTabsIntent
6 | import io.github.v2compose.core.extension.tryParse
7 |
8 | fun Context.openInBrowser(url: String, inExternalBrowser: Boolean = true) {
9 | val uri = url.tryParse() ?: return
10 | if (inExternalBrowser) {
11 | val defaultBrowser = getDefaultBrowser()
12 | val customTabsBrowsers = getCustomTabsBrowsers()
13 | if (customTabsBrowsers.contains(defaultBrowser)) {
14 | val customTabs = CustomTabsIntent.Builder().build()
15 | customTabs.intent.setPackage(defaultBrowser)
16 | customTabs.launchUrl(this, uri)
17 | return
18 | } else if (customTabsBrowsers.isNotEmpty()) {
19 | val customTabs = CustomTabsIntent.Builder().build()
20 | customTabs.intent.setPackage(customTabsBrowsers[0])
21 | customTabs.launchUrl(this, uri)
22 | return
23 | }
24 | }
25 | startActivity(Intent(Intent.ACTION_VIEW, uri))
26 | }
--------------------------------------------------------------------------------
/app/src/main/java/io/github/v2compose/ui/common/StateList.kt:
--------------------------------------------------------------------------------
1 | package io.github.v2compose.ui.common
2 |
3 | import androidx.compose.runtime.Composable
4 | import androidx.compose.runtime.saveable.listSaver
5 | import androidx.compose.runtime.saveable.rememberSaveable
6 | import androidx.compose.runtime.snapshots.SnapshotStateList
7 | import androidx.compose.runtime.toMutableStateList
8 |
9 | @Composable
10 | fun rememberMutableStateListOf(vararg elements: T): SnapshotStateList {
11 | return rememberSaveable(
12 | saver = listSaver(
13 | save = { stateList ->
14 | if (stateList.isNotEmpty()) {
15 | val first = stateList.first()
16 | if (!canBeSaved(first)) {
17 | throw IllegalStateException("${first::class} cannot be saved. By default only types which can be stored in the Bundle class can be saved.")
18 | }
19 | }
20 | stateList.toList()
21 | },
22 | restore = { it.toMutableStateList() }
23 | )
24 | ) {
25 | elements.toList().toMutableStateList()
26 | }
27 | }
--------------------------------------------------------------------------------
/app/src/main/java/io/github/v2compose/ui/main/MainScreenState.kt:
--------------------------------------------------------------------------------
1 | package io.github.v2compose.ui.main
2 |
3 | import android.content.Context
4 | import androidx.compose.material3.SnackbarHostState
5 | import androidx.compose.runtime.Composable
6 | import androidx.compose.runtime.remember
7 | import androidx.compose.runtime.rememberCoroutineScope
8 | import androidx.compose.ui.platform.LocalContext
9 | import io.github.v2compose.LocalSnackbarHostState
10 | import io.github.v2compose.ui.BaseScreenState
11 | import kotlinx.coroutines.CoroutineScope
12 |
13 | @Composable
14 | fun rememberMainScreenState(
15 | context: Context = LocalContext.current,
16 | coroutineScope: CoroutineScope = rememberCoroutineScope(),
17 | snackbarHostState: SnackbarHostState = LocalSnackbarHostState.current
18 | ): MainScreenState {
19 | return remember(context, snackbarHostState) {
20 | MainScreenState(context, coroutineScope, snackbarHostState)
21 | }
22 | }
23 |
24 | class MainScreenState(
25 | context: Context,
26 | coroutineScope: CoroutineScope,
27 | snackbarHostState: SnackbarHostState,
28 | ) : BaseScreenState(context, coroutineScope, snackbarHostState)
--------------------------------------------------------------------------------
/app/src/main/res/anim/slide_in_right.xml:
--------------------------------------------------------------------------------
1 |
2 |
20 |
21 |
23 |
25 |
--------------------------------------------------------------------------------
/app/src/main/java/io/github/v2compose/ui/theme/Type.kt:
--------------------------------------------------------------------------------
1 | package io.github.v2compose.ui.theme
2 |
3 | import androidx.compose.material3.Typography
4 | import androidx.compose.ui.text.TextStyle
5 | import androidx.compose.ui.text.font.FontFamily
6 | import androidx.compose.ui.text.font.FontWeight
7 | import androidx.compose.ui.unit.sp
8 |
9 | // Set of Material typography styles to start with
10 | val Typography = Typography(
11 | bodyLarge = TextStyle(
12 | fontFamily = FontFamily.Default,
13 | fontWeight = FontWeight.Normal,
14 | fontSize = 16.sp,
15 | lineHeight = 24.sp,
16 | letterSpacing = 0.5.sp
17 | )
18 | /* Other default text styles to override
19 | titleLarge = TextStyle(
20 | fontFamily = FontFamily.Default,
21 | fontWeight = FontWeight.Normal,
22 | fontSize = 22.sp,
23 | lineHeight = 28.sp,
24 | letterSpacing = 0.sp
25 | ),
26 | labelSmall = TextStyle(
27 | fontFamily = FontFamily.Default,
28 | fontWeight = FontWeight.Medium,
29 | fontSize = 11.sp,
30 | lineHeight = 16.sp,
31 | letterSpacing = 0.5.sp
32 | )
33 | */
34 | )
--------------------------------------------------------------------------------
/app/src/main/java/io/github/v2compose/ui/webview/WebViewNavigation.kt:
--------------------------------------------------------------------------------
1 | package io.github.v2compose.ui.webview
2 |
3 | import android.net.Uri
4 | import androidx.compose.animation.ExperimentalAnimationApi
5 | import androidx.navigation.NavController
6 | import androidx.navigation.NavGraphBuilder
7 | import androidx.navigation.NavType
8 | import androidx.navigation.navArgument
9 | import com.google.accompanist.navigation.animation.composable
10 |
11 | private const val argsUrl = "url"
12 | const val webViewNavigationRoute = "/webview?$argsUrl={$argsUrl}"
13 |
14 | fun NavController.navigateToWebView(url: String) {
15 | val encodeUrl = Uri.encode(url)
16 | navigate("/webview?url=$encodeUrl")
17 | }
18 |
19 | @OptIn(ExperimentalAnimationApi::class)
20 | fun NavGraphBuilder.webViewScreen(onCloseClick: () -> Unit, openUri:(String) -> Unit) {
21 | composable(
22 | webViewNavigationRoute,
23 | arguments = listOf(navArgument(argsUrl) { type = NavType.StringType })
24 | ) {
25 | val url = Uri.decode(it.arguments?.getString(argsUrl)) ?: ""
26 | WebViewScreenRoute(url = url, onCloseClick = onCloseClick, openUri = openUri)
27 | }
28 | }
--------------------------------------------------------------------------------
/app/src/main/res/anim/slide_out_left.xml:
--------------------------------------------------------------------------------
1 |
2 |
20 |
21 |
23 |
25 |
--------------------------------------------------------------------------------
/app/src/main/java/io/github/v2compose/repository/di/DataModule.kt:
--------------------------------------------------------------------------------
1 | package io.github.v2compose.repository.di
2 |
3 | import dagger.Binds
4 | import dagger.Module
5 | import dagger.hilt.InstallIn
6 | import dagger.hilt.components.SingletonComponent
7 | import io.github.v2compose.repository.*
8 | import io.github.v2compose.repository.def.*
9 |
10 | @Module
11 | @InstallIn(SingletonComponent::class)
12 | abstract class DataModule {
13 |
14 | @Binds
15 | abstract fun provideAppRepository(defaultAppRepository: DefaultAppRepository): AppRepository
16 |
17 | @Binds
18 | abstract fun provideNewsRepository(defaultNewsRepository: DefaultNewsRepository): NewsRepository
19 |
20 | @Binds
21 | abstract fun provideNodeRepository(defaultNodeRepository: DefaultNodeRepository): NodeRepository
22 |
23 | @Binds
24 | abstract fun provideTopicRepository(defaultTopicRepository: DefaultTopicRepository): TopicRepository
25 |
26 | @Binds
27 | abstract fun provideUserRepository(defaultUserRepository: DefaultUserRepository): UserRepository
28 |
29 | @Binds
30 | abstract fun provideAccountRepository(defaultAccountRepository: DefaultAccountRepository): AccountRepository
31 |
32 | }
--------------------------------------------------------------------------------
/app/src/main/java/io/github/v2compose/network/GithubService.kt:
--------------------------------------------------------------------------------
1 | package io.github.v2compose.network
2 |
3 | import com.google.gson.Gson
4 | import io.github.v2compose.network.bean.Release
5 | import okhttp3.OkHttpClient
6 | import retrofit2.Retrofit
7 | import retrofit2.converter.gson.GsonConverterFactory
8 | import retrofit2.http.GET
9 | import retrofit2.http.Path
10 |
11 | interface GithubService {
12 |
13 | companion object {
14 | private const val BaseUrl = "https://api.github.com/"
15 |
16 | fun createGithubApi(httpClient: OkHttpClient, gson: Gson): GithubService {
17 | val retrofit = Retrofit.Builder()
18 | .client(httpClient)
19 | .addConverterFactory(GsonConverterFactory.create(gson))
20 | .baseUrl(BaseUrl)
21 | .build()
22 | return retrofit.create(GithubService::class.java)
23 | }
24 | }
25 |
26 | //eg : https://api.github.com/repos/tachiyomiorg/tachiyomi/releases/latest
27 | @GET("/repos/{owner}/{repo}/releases/latest")
28 | suspend fun getTheLatestRelease(
29 | @Path("owner") owner: String,
30 | @Path("repo") repo: String
31 | ): Release
32 |
33 | }
--------------------------------------------------------------------------------
/app/src/main/java/io/github/v2compose/ui/user/UserScreenState.kt:
--------------------------------------------------------------------------------
1 | package io.github.v2compose.ui.user
2 |
3 | import android.content.Context
4 | import androidx.compose.material3.SnackbarHostState
5 | import androidx.compose.runtime.Composable
6 | import androidx.compose.runtime.remember
7 | import androidx.compose.runtime.rememberCoroutineScope
8 | import androidx.compose.ui.platform.LocalContext
9 | import io.github.v2compose.LocalSnackbarHostState
10 | import io.github.v2compose.ui.BaseScreenState
11 | import kotlinx.coroutines.CoroutineScope
12 |
13 | @Composable
14 | fun rememberUserScreenState(
15 | context: Context = LocalContext.current,
16 | coroutineScope: CoroutineScope = rememberCoroutineScope(),
17 | snackbarHostState: SnackbarHostState = LocalSnackbarHostState.current,
18 | ): UserScreenState {
19 | return remember(context, coroutineScope, snackbarHostState) {
20 | UserScreenState(context, coroutineScope, snackbarHostState)
21 | }
22 | }
23 |
24 | class UserScreenState(
25 | context: Context,
26 | coroutineScope: CoroutineScope,
27 | snackbarHostState: SnackbarHostState,
28 | ) : BaseScreenState(context, coroutineScope, snackbarHostState) {
29 |
30 |
31 | }
--------------------------------------------------------------------------------
/app/src/main/java/io/github/v2compose/V2exUri.kt:
--------------------------------------------------------------------------------
1 | package io.github.v2compose
2 |
3 | object V2exUri {
4 |
5 | const val myTopicsUrl = "https://v2ex.com/my/topics"
6 | const val myNodesUrl = "https://v2ex.com/my/nodes"
7 | const val myFollowingUrl = "https://v2ex.com/my/following"
8 |
9 | const val missionDailyPath = "/mission/daily"
10 |
11 | fun topicUrl(topicId: String) = "${Constants.baseUrl}/t/$topicId"
12 |
13 | fun userUrl(userName: String) = Constants.baseUrl + userPath(userName)
14 |
15 | fun nodeUrl(nodeName: String) = Constants.baseUrl + nodePath(nodeName)
16 |
17 | fun topicPath(topicId: String, replyFloor: Int = 0): String {
18 | return "/t/$topicId#reply$replyFloor"
19 | }
20 |
21 | fun nodePath(nodeName: String) = "/go/$nodeName"
22 |
23 | fun userPath(userName: String) = "/member/$userName"
24 |
25 | fun String.isUserPath() = this.startsWith(Constants.userPath)
26 |
27 |
28 | fun fixUriWithTopicPath(uri: String, topicPath: String): String {
29 | return if (uri.startsWith("#reply")) {
30 | topicPath.replace("#reply\\d+".toRegex(), "") + uri
31 | } else {
32 | uri
33 | }
34 | }
35 | }
--------------------------------------------------------------------------------
/app/src/main/java/io/github/v2compose/ui/login/google/GoogleLoginNavigation.kt:
--------------------------------------------------------------------------------
1 | package io.github.v2compose.ui.login.google
2 |
3 | import androidx.compose.animation.ExperimentalAnimationApi
4 | import androidx.navigation.NavController
5 | import androidx.navigation.NavGraphBuilder
6 | import androidx.navigation.NavType
7 | import androidx.navigation.navArgument
8 | import com.google.accompanist.navigation.animation.composable
9 |
10 | private const val argsOnce = "once"
11 |
12 | const val googleLoginNavigationRoute = "/auth/google?$argsOnce={$argsOnce}"
13 |
14 | fun NavController.navigateToGoogleLogin(once: String) {
15 | navigate("/auth/google?$argsOnce=$once")
16 | }
17 |
18 | @OptIn(ExperimentalAnimationApi::class)
19 | fun NavGraphBuilder.googleLoginScreen(onCloseClick: () -> Unit, onLoginSuccess: () -> Unit) {
20 | composable(
21 | googleLoginNavigationRoute,
22 | arguments = listOf(navArgument(argsOnce) { type = NavType.StringType })
23 | ) {
24 | val once = it.arguments?.getString(argsOnce) ?: ""
25 | GoogleLoginScreenRoute(
26 | once = once,
27 | onCloseClick = onCloseClick,
28 | onLoginSuccess = onLoginSuccess,
29 | )
30 | }
31 | }
32 |
--------------------------------------------------------------------------------
/htmlText/src/main/java/io/github/cooaer/htmltext/StateImage.kt:
--------------------------------------------------------------------------------
1 | package io.github.cooaer.htmltext
2 |
3 | import androidx.compose.foundation.background
4 | import androidx.compose.foundation.layout.Box
5 | import androidx.compose.material3.CircularProgressIndicator
6 | import androidx.compose.material3.MaterialTheme
7 | import androidx.compose.material3.Text
8 | import androidx.compose.runtime.Composable
9 | import androidx.compose.ui.Alignment
10 | import androidx.compose.ui.Modifier
11 |
12 | @Composable
13 | fun LoadingImage(modifier: Modifier = Modifier) {
14 | Box(
15 | modifier = modifier.background(MaterialTheme.colorScheme.surfaceVariant),
16 | ) {
17 | CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
18 | }
19 | }
20 |
21 | @Composable
22 | fun ErrorImage(modifier: Modifier = Modifier, error: Throwable? = null) {
23 | Box(
24 | modifier = modifier.background(MaterialTheme.colorScheme.errorContainer),
25 | ) {
26 | Text(
27 | "加载出错,请重试",
28 | color = MaterialTheme.colorScheme.onErrorContainer,
29 | style = MaterialTheme.typography.titleLarge,
30 | modifier = Modifier.align(Alignment.Center)
31 | )
32 | }
33 | }
--------------------------------------------------------------------------------
/app/src/main/java/io/github/v2compose/ui/common/TextAlertDialog.kt:
--------------------------------------------------------------------------------
1 | package io.github.v2compose.ui.common
2 |
3 | import androidx.compose.material3.AlertDialog
4 | import androidx.compose.material3.MaterialTheme
5 | import androidx.compose.material3.Text
6 | import androidx.compose.material3.TextButton
7 | import androidx.compose.runtime.Composable
8 | import androidx.compose.ui.res.stringResource
9 | import io.github.v2compose.R
10 |
11 |
12 | @Composable
13 | fun TextAlertDialog(
14 | title: String? = null,
15 | message: String,
16 | onDismiss: () -> Unit,
17 | onConfirm: () -> Unit
18 | ) {
19 | AlertDialog(
20 | onDismissRequest = onDismiss,
21 | title = { title?.let { Text(title) }},
22 | text = { Text(message, style = MaterialTheme.typography.bodyLarge) },
23 | confirmButton = {
24 | TextButton(onClick = {
25 | onDismiss()
26 | onConfirm()
27 | }) {
28 | Text(stringResource(id = R.string.ok))
29 | }
30 | },
31 | dismissButton = {
32 | TextButton(onClick = { onDismiss() }) {
33 | Text(stringResource(id = R.string.cancel))
34 | }
35 | },
36 | )
37 | }
38 |
--------------------------------------------------------------------------------
/app/src/main/java/io/github/v2compose/network/bean/LoginResultInfo.java:
--------------------------------------------------------------------------------
1 | package io.github.v2compose.network.bean;
2 |
3 | import io.github.v2compose.util.Check;
4 | import me.ghui.fruit.annotations.Pick;
5 |
6 | /**
7 | * Created by ghui on 16/08/2017.
8 | */
9 |
10 | @Pick("header#site-header")
11 | public class LoginResultInfo extends BaseInfo {
12 | @Pick(value = "[href^=/member]", attr = "href")
13 | private String userLink;
14 | @Pick(value = "img[src*=avatar/]", attr = "src")
15 | private String avatar;
16 |
17 | @Override
18 | public boolean isValid() {
19 | return Check.notEmpty(avatar) && Check.notEmpty(avatar);
20 | }
21 |
22 | @Override
23 | public String toString() {
24 | return "LoginResultInfo{" +
25 | "userLink='" + userLink + '\'' +
26 | ", avatar='" + avatar + '\'' +
27 | '}';
28 | }
29 |
30 | public String getUserName() {
31 | if (Check.isEmpty(userLink)) {
32 | return null;
33 | }
34 | return userLink.split("/")[2];
35 | }
36 |
37 | public String getAvatar() {
38 | if (Check.isEmpty(avatar)) return null;
39 | return avatar.replace("normal.png", "large.png");
40 | }
41 |
42 | }
43 |
--------------------------------------------------------------------------------
/app/src/main/java/io/github/v2compose/ui/webview/client/V2exWebViewClient.kt:
--------------------------------------------------------------------------------
1 | package io.github.v2compose.ui.webview.client
2 |
3 | import android.webkit.HttpAuthHandler
4 | import android.webkit.WebResourceRequest
5 | import android.webkit.WebView
6 | import com.google.accompanist.web.AccompanistWebViewClient
7 |
8 | class V2exWebViewClient(private val openUri: (String) -> Unit) : AccompanistWebViewClient() {
9 | override fun shouldOverrideUrlLoading(view: WebView?, request: WebResourceRequest?): Boolean {
10 | if(interceptUrl(request)) return true
11 | return super.shouldOverrideUrlLoading(view, request)
12 | }
13 |
14 | private fun interceptUrl(request: WebResourceRequest?): Boolean {
15 | request?.url?.pathSegments?.firstOrNull()?.let {
16 | if (listOf("t", "go", "member").contains(it.lowercase())) {
17 | openUri(request.url.toString())
18 | return true
19 | }
20 | }
21 | return false
22 | }
23 |
24 | override fun onReceivedHttpAuthRequest(
25 | view: WebView?,
26 | handler: HttpAuthHandler?,
27 | host: String?,
28 | realm: String?
29 | ) {
30 | super.onReceivedHttpAuthRequest(view, handler, host, realm)
31 | }
32 |
33 | }
--------------------------------------------------------------------------------
/app/src/main/java/io/github/v2compose/ui/supplement/AddSupplementScreenState.kt:
--------------------------------------------------------------------------------
1 | package io.github.v2compose.ui.supplement
2 |
3 | import android.content.Context
4 | import androidx.compose.material3.SnackbarHostState
5 | import androidx.compose.runtime.Composable
6 | import androidx.compose.runtime.Stable
7 | import androidx.compose.runtime.remember
8 | import androidx.compose.runtime.rememberCoroutineScope
9 | import androidx.compose.ui.platform.LocalContext
10 | import io.github.v2compose.LocalSnackbarHostState
11 | import io.github.v2compose.ui.BaseScreenState
12 | import kotlinx.coroutines.CoroutineScope
13 |
14 | @Composable
15 | fun rememberAddSupplementScreenState(
16 | context: Context = LocalContext.current,
17 | coroutineScope: CoroutineScope = rememberCoroutineScope(),
18 | snackbarHostState: SnackbarHostState = LocalSnackbarHostState.current,
19 | ): AddSupplementScreenState {
20 | return remember(context, coroutineScope, snackbarHostState) {
21 | AddSupplementScreenState(context, coroutineScope, snackbarHostState)
22 | }
23 | }
24 |
25 | @Stable
26 | class AddSupplementScreenState(
27 | context: Context,
28 | coroutineScope: CoroutineScope,
29 | snackbarHostState: SnackbarHostState
30 | ): BaseScreenState(context, coroutineScope, snackbarHostState) {
31 |
32 |
33 | }
--------------------------------------------------------------------------------
/app/src/main/java/io/github/v2compose/ui/login/LoginNavigation.kt:
--------------------------------------------------------------------------------
1 | package io.github.v2compose.ui.login
2 |
3 | import android.net.Uri
4 | import androidx.compose.animation.ExperimentalAnimationApi
5 | import androidx.navigation.*
6 | import com.google.accompanist.navigation.animation.composable
7 |
8 | private const val argsNext = "next"
9 | const val loginNavigationRoute = "/signin?next={$argsNext}"
10 |
11 | fun NavController.navigateToLogin(
12 | next: String? = null,
13 | navOptions: NavOptions? = null,
14 | ) {
15 | val encodedNext = Uri.encode(next) ?: ""
16 | navigate("/signin?next=$encodedNext", navOptions = navOptions)
17 | }
18 |
19 | @OptIn(ExperimentalAnimationApi::class)
20 | fun NavGraphBuilder.loginScreen(
21 | onCloseClick: () -> Unit,
22 | onSignInWithGoogleClick: (String) -> Unit,
23 | ) {
24 | composable(
25 | route = loginNavigationRoute,
26 | arguments = listOf(navArgument(argsNext) {
27 | type = NavType.StringType
28 | nullable = true
29 | })
30 | ) {
31 | val redirect = it.arguments?.getString(argsNext)
32 | LoginScreenRoute(
33 | onCloseClick = onCloseClick,
34 | onSignInWithGoogleClick = onSignInWithGoogleClick,
35 | redirect = redirect,
36 | )
37 | }
38 | }
--------------------------------------------------------------------------------
/app/src/main/java/io/github/v2compose/usecase/CheckInUseCase.kt:
--------------------------------------------------------------------------------
1 | package io.github.v2compose.usecase
2 |
3 | import io.github.v2compose.core.extension.isRedirect
4 | import io.github.v2compose.repository.AccountRepository
5 | import io.github.v2compose.V2exUri
6 | import javax.inject.Inject
7 |
8 | class CheckInUseCase @Inject constructor(
9 | private val accountRepository: AccountRepository,
10 | ) {
11 |
12 | //状态变化后的自动签到、点击签到按钮、后台自动签到
13 | suspend operator fun invoke(): CheckInResult {
14 | return try {
15 | var dailyInfo = accountRepository.dailyInfo()
16 | if (!dailyInfo.hadCheckedIn()) {
17 | dailyInfo = accountRepository.checkIn(dailyInfo.once())
18 | }
19 | CheckInResult(dailyInfo.hadCheckedIn(), dailyInfo.continuousLoginDaysText)
20 | } catch (e: Exception) {
21 | e.printStackTrace()
22 | if (e.isRedirect(V2exUri.missionDailyPath)) {
23 | val dailyInfo = accountRepository.dailyInfo()
24 | CheckInResult(dailyInfo.hadCheckedIn(), dailyInfo.continuousLoginDaysText)
25 | } else {
26 | CheckInResult(false, e.message)
27 | }
28 | }
29 | }
30 |
31 | }
32 |
33 | data class CheckInResult(val success: Boolean, val message: String?)
--------------------------------------------------------------------------------
/app/src/main/java/io/github/v2compose/ui/topic/bean/TopicInfoWrapper.kt:
--------------------------------------------------------------------------------
1 | package io.github.v2compose.ui.topic.bean
2 |
3 | import io.github.v2compose.network.bean.TopicInfo
4 |
5 | data class TopicInfoWrapper(
6 | val topic: TopicInfo? = null,
7 | val favorited: Boolean? = null,
8 | val thanked: Boolean? = null,
9 | val ignored: Boolean? = null,
10 | val reported: Boolean? = null,
11 | ) {
12 |
13 | val favoriteCount: Int
14 | get() {
15 | val innerCount = topic?.headerInfo?.favoriteCount ?: 0
16 | val innerFavorited = topic?.headerInfo?.hadFavorited()
17 | if(favorited == true && innerFavorited == false){
18 | return innerCount + 1
19 | }else if(favorited == false && innerFavorited == true){
20 | return innerCount - 1
21 | }
22 | return innerCount
23 | }
24 |
25 | val isFavorited: Boolean
26 | get() = favorited ?: topic?.headerInfo?.hadFavorited() ?: false
27 |
28 | val isThanked: Boolean
29 | get() = thanked ?: topic?.headerInfo?.hadThanked() ?: false
30 |
31 | val isIgnored: Boolean
32 | get() = ignored ?: topic?.headerInfo?.hadIgnored() ?: false
33 |
34 | val isReported: Boolean
35 | get() = reported ?: topic?.hasReported() ?: false
36 | }
37 |
38 |
--------------------------------------------------------------------------------
/app/src/main/java/io/github/v2compose/usecase/CheckForUpdatesUseCase.kt:
--------------------------------------------------------------------------------
1 | package io.github.v2compose.usecase
2 |
3 | import io.github.v2compose.BuildConfig
4 | import io.github.v2compose.core.extension.newerThan
5 | import io.github.v2compose.core.extension.toAppVersion
6 | import io.github.v2compose.datasource.AppPreferences
7 | import io.github.v2compose.network.bean.Release
8 | import io.github.v2compose.repository.AppRepository
9 | import kotlinx.coroutines.flow.first
10 | import javax.inject.Inject
11 |
12 | class CheckForUpdatesUseCase @Inject constructor(
13 | private val appRepository: AppRepository,
14 | private val appPreferences: AppPreferences,
15 | ) {
16 |
17 | suspend operator fun invoke(force: Boolean = false): Release {
18 | val release = try {
19 | appRepository.getAppLatestRelease()
20 | } catch (e: Exception) {
21 | e.printStackTrace()
22 | return Release.Empty
23 | }
24 | val appSettings = appPreferences.appSettings.first()
25 | if (!force && appSettings.ignoredReleaseName == release.tagName) {
26 | return Release.Empty
27 | }
28 | if (release.tagName.toAppVersion().newerThan(BuildConfig.VERSION_NAME.toAppVersion())) {
29 | return release
30 | }
31 | return Release.Empty
32 | }
33 |
34 | }
--------------------------------------------------------------------------------
/app/src/main/java/io/github/v2compose/network/bean/Release.kt:
--------------------------------------------------------------------------------
1 | package io.github.v2compose.network.bean
2 |
3 | data class Release(
4 | val id: Int,
5 | val name: String?,
6 | val body: String?,
7 | val tagName: String,
8 | val htmlUrl: String,
9 | ) {
10 | companion object {
11 | val Empty = Release(0, "", "", "", "")
12 |
13 | fun fromMap(map:Map):Release{
14 | return Release(
15 | id = map["id"] as Int,
16 | name = map["name"] as String?,
17 | body = map["body"] as String?,
18 | tagName = map["tagName"] as String,
19 | htmlUrl = map["htmlUrl"] as String,
20 | )
21 | }
22 | }
23 |
24 | fun isValid(): Boolean {
25 | return id > 0 && tagName.isNotEmpty() && htmlUrl.isNotEmpty()
26 | }
27 |
28 | fun toMap():Map{
29 | return mapOf(
30 | "id" to id,
31 | "name" to name,
32 | "body" to body,
33 | "tagName" to tagName,
34 | "htmlUrl" to htmlUrl,
35 | )
36 | }
37 | }
38 |
39 | data class ReleaseAsset(
40 | val id: Int,
41 | val name: String,
42 | val contentType: String,
43 | val size: Int,
44 | val downloadCount: Int,
45 | val browserDownloadUrl: String,
46 | )
--------------------------------------------------------------------------------
/app/src/main/java/io/github/v2compose/core/extension/DateTimeString.kt:
--------------------------------------------------------------------------------
1 | package io.github.v2compose.core.extension
2 |
3 | import android.content.Context
4 | import io.github.v2compose.R
5 | import java.text.SimpleDateFormat
6 | import java.util.*
7 |
8 | private const val UTC_TIME_PATTERN = "yyyy-MM-dd'T'HH:mm:ss"
9 |
10 | fun String.toDateTime(): Date? {
11 | return SimpleDateFormat(UTC_TIME_PATTERN).parse(this)
12 | }
13 |
14 |
15 | fun String.toTimeText(context: Context): String {
16 | val timeMills = toDateTime()?.time ?: return this
17 |
18 | val timeDelta = System.currentTimeMillis() - timeMills
19 | val minMills = 60 * 1000
20 | val hourMills = 60 * minMills
21 | val dayMills = 24 * hourMills
22 |
23 | if (timeDelta >= 8 * dayMills) {
24 | val newFormatter = SimpleDateFormat("yyyy-MM-dd", Locale.getDefault())
25 | return newFormatter.format(Date(timeMills))
26 | }
27 | if (timeDelta >= dayMills) {
28 | return context.getString(R.string.n_days_ago, timeDelta / dayMills)
29 | }
30 | if (timeDelta >= hourMills) {
31 | return context.getString(R.string.n_hours_ago, timeDelta / hourMills)
32 | }
33 | if (timeDelta >= minMills) {
34 | return context.getString(R.string.n_minutes_ago, timeDelta / minMills)
35 | }
36 | return context.getString(R.string.just_now)
37 | }
--------------------------------------------------------------------------------
/app/src/main/java/io/github/v2compose/util/AvatarUtils.java:
--------------------------------------------------------------------------------
1 | package io.github.v2compose.util;
2 |
3 | import io.github.v2compose.network.NetConstants;
4 |
5 | /**
6 | * Created by ghui on 23/06/2017.
7 | */
8 |
9 | public class AvatarUtils {
10 | public static String adjustAvatar(String avatar) {
11 | if (Check.isEmpty(avatar)) return null;
12 | //1.
13 | if (!avatar.startsWith(NetConstants.HTTPS_SCHEME) && !avatar.startsWith(NetConstants.HTTP_SCHEME)) {
14 | if (avatar.startsWith("//")) {
15 | avatar = NetConstants.HTTPS_SCHEME + avatar;
16 | } else if (avatar.startsWith("/")) {
17 | avatar = NetConstants.BASE_URL + avatar;
18 | }
19 | }
20 |
21 | //2.
22 | if (avatar.contains("_normal.png")) {
23 | avatar = avatar.replace("_normal.png", "_large.png");
24 | } else if (avatar.contains("_mini.png")) {
25 | avatar = avatar.replace("_mini.png", "_large.png");
26 | }
27 |
28 | if (avatar.contains("_xxlarge.png")) {
29 | avatar = avatar.replace("_xxlarge.png", "_large.png");
30 | }
31 |
32 | //3. del param
33 | // if (avatar.contains("?")) {
34 | // avatar = avatar.substring(0, avatar.indexOf("?"));
35 | // }
36 | return avatar;
37 | }
38 | }
39 |
--------------------------------------------------------------------------------
/app/src/main/java/io/github/v2compose/ui/common/PullToRefresh.kt:
--------------------------------------------------------------------------------
1 | package io.github.v2compose.ui.common
2 |
3 | import androidx.compose.foundation.layout.Box
4 | import androidx.compose.foundation.layout.fillMaxSize
5 | import androidx.compose.material.ExperimentalMaterialApi
6 | import androidx.compose.material.pullrefresh.PullRefreshIndicator
7 | import androidx.compose.material.pullrefresh.pullRefresh
8 | import androidx.compose.material.pullrefresh.rememberPullRefreshState
9 | import androidx.compose.runtime.*
10 | import androidx.compose.ui.Alignment
11 | import androidx.compose.ui.Modifier
12 | import kotlinx.coroutines.delay
13 |
14 | private const val TAG = "PullToRefresh"
15 |
16 | @OptIn(ExperimentalMaterialApi::class)
17 | @Composable
18 | fun PullToRefresh(
19 | refreshing: Boolean,
20 | onRefresh: () -> Unit,
21 | content: @Composable () -> Unit
22 | ) {
23 | val pullRefreshState = rememberPullRefreshState(
24 | refreshing = refreshing,
25 | onRefresh = onRefresh,
26 | )
27 |
28 | Box(
29 | Modifier
30 | .pullRefresh(pullRefreshState)
31 | .fillMaxSize()
32 | ) {
33 |
34 | content()
35 |
36 | PullRefreshIndicator(
37 | refreshing = refreshing,
38 | state = pullRefreshState,
39 | modifier = Modifier.align(Alignment.TopCenter)
40 | )
41 | }
42 | }
--------------------------------------------------------------------------------
/app/src/main/java/io/github/v2compose/datasource/SearchPagingSource.kt:
--------------------------------------------------------------------------------
1 | package io.github.v2compose.datasource
2 |
3 | import androidx.paging.PagingSource
4 | import androidx.paging.PagingState
5 | import io.github.v2compose.network.V2exService
6 | import io.github.v2compose.network.bean.SoV2EXSearchResultInfo
7 |
8 | class SearchPagingSource(private val keyword: String, private val v2exService: V2exService) :
9 | PagingSource() {
10 |
11 | override fun getRefreshKey(state: PagingState): Int? {
12 | return null
13 | }
14 |
15 | override suspend fun load(params: LoadParams): LoadResult {
16 | return try {
17 | val from = params.key ?: 0
18 | val loadSize = params.loadSize
19 | val resultInfo = v2exService.search(keyword = keyword, from = from, size = loadSize)
20 | val prevKey = if (from == 0) null else from - loadSize
21 | val nextKey = if (from + loadSize < resultInfo.total) from + loadSize else null
22 | return LoadResult.Page(
23 | data = resultInfo.hits,
24 | prevKey = prevKey,
25 | nextKey = nextKey,
26 | )
27 | } catch (e: Exception) {
28 | e.printStackTrace()
29 | LoadResult.Error(e)
30 | }
31 | }
32 | }
--------------------------------------------------------------------------------
/app/src/main/java/io/github/v2compose/usecase/LoadNodesUseCase.kt:
--------------------------------------------------------------------------------
1 | package io.github.v2compose.usecase
2 |
3 | import androidx.compose.runtime.Stable
4 | import io.github.v2compose.network.bean.TopicNode
5 | import io.github.v2compose.repository.TopicRepository
6 | import kotlinx.coroutines.flow.MutableStateFlow
7 | import kotlinx.coroutines.flow.StateFlow
8 | import javax.inject.Inject
9 |
10 | class LoadNodesUseCase @Inject constructor(private val topicRepository: TopicRepository) {
11 |
12 | private val _state = MutableStateFlow(LoadNodesState.Idle)
13 | val state: StateFlow = _state
14 |
15 | suspend fun execute() {
16 | _state.emit(LoadNodesState.Loading)
17 | try {
18 | val result = topicRepository.getTopicNodes()
19 | if (result.isNotEmpty()) {
20 | _state.emit(LoadNodesState.Success(result))
21 | } else {
22 | _state.emit(LoadNodesState.Error(null))
23 | }
24 | } catch (e: Exception) {
25 | e.printStackTrace()
26 | _state.emit(LoadNodesState.Error(e))
27 | }
28 | }
29 |
30 | }
31 |
32 | @Stable
33 | sealed interface LoadNodesState {
34 | object Idle : LoadNodesState
35 | object Loading : LoadNodesState
36 | data class Success(val data: List) : LoadNodesState
37 | data class Error(val error: Throwable?) : LoadNodesState
38 | }
39 |
--------------------------------------------------------------------------------
/app/src/main/java/io/github/v2compose/datasource/MyTopicsPagingSource.kt:
--------------------------------------------------------------------------------
1 | package io.github.v2compose.datasource
2 |
3 | import androidx.paging.PagingSource
4 | import androidx.paging.PagingState
5 | import io.github.v2compose.network.NetConstants
6 | import io.github.v2compose.network.V2exService
7 | import io.github.v2compose.network.bean.MyTopicsInfo
8 | import javax.inject.Inject
9 |
10 | class MyTopicsPagingSource @Inject constructor(private val v2exService: V2exService) :
11 | PagingSource() {
12 |
13 | override fun getRefreshKey(state: PagingState): Int? {
14 | return state.anchorPosition?.let { anchorPosition ->
15 | val anchorPage = state.closestPageToPosition(anchorPosition)
16 | anchorPage?.prevKey?.plus(1) ?: anchorPage?.nextKey?.minus(1)
17 | }
18 | }
19 |
20 | override suspend fun load(params: LoadParams): LoadResult {
21 | return try {
22 | val page = params.key ?: 1
23 | val result = v2exService.myTopicsInfo(page, NetConstants.systemUserAgent)
24 | val prevKey = if (page == 1) null else page - 1
25 | val nextKey = if (page < result.totalPageCount) page + 1 else null
26 | LoadResult.Page(result.items, prevKey, nextKey)
27 | } catch (e: Exception) {
28 | e.printStackTrace()
29 | LoadResult.Error(e)
30 | }
31 | }
32 | }
--------------------------------------------------------------------------------
/app/src/main/java/io/github/v2compose/core/result/Result.kt:
--------------------------------------------------------------------------------
1 | /*
2 | * Copyright 2022 The Android Open Source Project
3 | *
4 | * Licensed under the Apache License, Version 2.0 (the "License");
5 | * you may not use this file except in compliance with the License.
6 | * You may obtain a copy of the License at
7 | *
8 | * https://www.apache.org/licenses/LICENSE-2.0
9 | *
10 | * Unless required by applicable law or agreed to in writing, software
11 | * distributed under the License is distributed on an "AS IS" BASIS,
12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | * See the License for the specific language governing permissions and
14 | * limitations under the License.
15 | */
16 |
17 | package io.github.v2compose.core.result
18 |
19 | import androidx.compose.runtime.Stable
20 | import kotlinx.coroutines.flow.Flow
21 | import kotlinx.coroutines.flow.catch
22 | import kotlinx.coroutines.flow.map
23 | import kotlinx.coroutines.flow.onStart
24 |
25 | @Stable
26 | sealed interface Result {
27 | data class Success(val data: T) : Result
28 | data class Error(val exception: Throwable?) : Result
29 | object Loading : Result
30 | }
31 |
32 | fun Flow.asResult(): Flow> {
33 | return this
34 | .map> {
35 | Result.Success(it)
36 | }
37 | .onStart { emit(Result.Loading) }
38 | .catch { emit(Result.Error(it)) }
39 | }
40 |
--------------------------------------------------------------------------------
/app/src/main/java/io/github/v2compose/datasource/MyFollowingPagingSource.kt:
--------------------------------------------------------------------------------
1 | package io.github.v2compose.datasource
2 |
3 | import androidx.paging.PagingSource
4 | import androidx.paging.PagingState
5 | import io.github.v2compose.network.NetConstants
6 | import io.github.v2compose.network.V2exService
7 | import io.github.v2compose.network.bean.MyFollowingInfo
8 | import javax.inject.Inject
9 |
10 | class MyFollowingPagingSource @Inject constructor(private val v2exService: V2exService) :
11 | PagingSource() {
12 | override fun getRefreshKey(state: PagingState): Int? {
13 | return state.anchorPosition?.let { anchorPosition ->
14 | val anchorPage = state.closestPageToPosition(anchorPosition)
15 | anchorPage?.prevKey?.plus(1) ?: anchorPage?.nextKey?.minus(1)
16 | }
17 | }
18 |
19 | override suspend fun load(params: LoadParams): LoadResult {
20 | return try {
21 | val page = params.key ?: 1
22 | val result = v2exService.myFollowingInfo(page, NetConstants.systemUserAgent)
23 | val prevKey = if (page == 1) null else page - 1
24 | val nextKey = if (page < result.totalPageCount) page + 1 else null
25 | LoadResult.Page(result.items, prevKey, nextKey)
26 | } catch (e: Exception) {
27 | e.printStackTrace()
28 | LoadResult.Error(e)
29 | }
30 | }
31 | }
--------------------------------------------------------------------------------
/app/src/main/java/io/github/v2compose/datasource/UserRepliesDataSource.kt:
--------------------------------------------------------------------------------
1 | package io.github.v2compose.datasource
2 |
3 | import androidx.paging.PagingSource
4 | import androidx.paging.PagingState
5 | import io.github.v2compose.network.V2exService
6 | import io.github.v2compose.network.bean.UserReplies
7 |
8 | class UserRepliesDataSource(private val userName: String, private val v2exService: V2exService) :
9 | PagingSource() {
10 |
11 | companion object {
12 | const val FIRST_PAGE: Int = 1
13 | }
14 |
15 | override fun getRefreshKey(state: PagingState): Int? {
16 | return state.anchorPosition?.let { anchorPosition ->
17 | val anchorPage = state.closestPageToPosition(anchorPosition)
18 | anchorPage?.prevKey?.plus(1) ?: anchorPage?.nextKey?.minus(1)
19 | }
20 | }
21 |
22 | override suspend fun load(params: LoadParams): LoadResult {
23 | return try {
24 | val page = params.key ?: FIRST_PAGE
25 | val userReplies = v2exService.userReplies(userName, page)
26 | val prevKey = if (page == FIRST_PAGE) null else page - 1
27 | val nextKey = if (page < userReplies.pageCount) page + 1 else null
28 | LoadResult.Page(userReplies.items, prevKey, nextKey)
29 | } catch (e: Exception) {
30 | e.printStackTrace()
31 | LoadResult.Error(e)
32 | }
33 | }
34 | }
--------------------------------------------------------------------------------
/app/src/main/java/io/github/v2compose/repository/AccountRepository.kt:
--------------------------------------------------------------------------------
1 | package io.github.v2compose.repository
2 |
3 | import androidx.paging.PagingData
4 | import io.github.v2compose.bean.Account
5 | import io.github.v2compose.network.bean.*
6 | import kotlinx.coroutines.flow.Flow
7 |
8 | interface AccountRepository {
9 |
10 | val account: Flow
11 |
12 | val isLoggedIn: Flow
13 |
14 | val unreadNotifications: Flow
15 |
16 | fun getNotifications(): Flow>
17 |
18 | suspend fun resetNotificationCount()
19 |
20 | suspend fun getLoginParam(): LoginParam
21 |
22 | suspend fun login(loginParams: Map): LoginParam
23 |
24 | suspend fun getTwoStepLoginInfo(): TwoStepLoginInfo
25 |
26 | suspend fun loginNextStep(once: String, code: String): TwoStepLoginInfo
27 |
28 | suspend fun logout(): Boolean
29 |
30 | suspend fun getHomePageInfo(): HomePageInfo
31 |
32 | suspend fun fetchUserInfo()
33 |
34 | suspend fun refreshAccount()
35 |
36 | //签到
37 |
38 | val hasCheckingInTips: Flow
39 |
40 | val autoCheckIn: Flow
41 |
42 | val lastCheckInTime: Flow
43 |
44 | suspend fun dailyInfo(): DailyInfo
45 |
46 | suspend fun checkIn(once: String): DailyInfo
47 |
48 | val myTopics: Flow>
49 | val myFollowing: Flow>
50 | suspend fun getMyNodes(): MyNodesInfo
51 |
52 | }
--------------------------------------------------------------------------------
/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=-Xmx2048m -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 | android.enableJetifier=true
19 | # Kotlin code style for this project: "official" or "obsolete":
20 | kotlin.code.style=official
21 | # Enables namespacing of each library's R class so that its R class includes only the
22 | # resources declared in the library itself and none from the library's dependencies,
23 | # thereby reducing the size of the R class for that library
24 | android.nonTransitiveRClass=true
25 | org.gradle.unsafe.configuration-cache=true
--------------------------------------------------------------------------------
/app/src/main/java/io/github/v2compose/ui/common/Divider.kt:
--------------------------------------------------------------------------------
1 | package io.github.v2compose.ui.common
2 |
3 | import androidx.compose.foundation.background
4 | import androidx.compose.foundation.layout.*
5 | import androidx.compose.material3.Divider
6 | import androidx.compose.material3.DividerDefaults
7 | import androidx.compose.runtime.Composable
8 | import androidx.compose.ui.Modifier
9 | import androidx.compose.ui.graphics.Color
10 | import androidx.compose.ui.unit.Dp
11 | import androidx.compose.ui.unit.dp
12 |
13 | @Composable
14 | fun VerticalDivider(
15 | modifier: Modifier = Modifier,
16 | thickness: Dp = DividerDefaults.Thickness,
17 | color: Color = DividerDefaults.color,
18 | ) {
19 | Divider(
20 | modifier = modifier
21 | .fillMaxHeight()
22 | .width(thickness),
23 | thickness = thickness,
24 | color = color
25 | )
26 | }
27 |
28 | @Composable
29 | fun ListDivider(
30 | modifier: Modifier = Modifier,
31 | thickness: Dp = DividerDefaults.Thickness,
32 | color: Color = DividerDefaults.color,
33 | ) {
34 | Divider(
35 | modifier = modifier,
36 | thickness = Dp.Hairline,
37 | color = color
38 | )
39 | }
40 |
41 | @Composable
42 | fun WideDivider(
43 | modifier: Modifier = Modifier,
44 | size: Dp = 8.dp,
45 | color: Color = Color(0xfff5f5f5)
46 | ) {
47 | Box(
48 | modifier
49 | .fillMaxWidth()
50 | .height(size)
51 | .background(color = color)
52 | )
53 | }
--------------------------------------------------------------------------------
/app/src/main/java/io/github/v2compose/ui/supplement/AddSupplementNavigation.kt:
--------------------------------------------------------------------------------
1 | package io.github.v2compose.ui.supplement
2 |
3 | import androidx.compose.animation.ExperimentalAnimationApi
4 | import androidx.lifecycle.SavedStateHandle
5 | import androidx.navigation.NavController
6 | import androidx.navigation.NavGraphBuilder
7 | import androidx.navigation.NavType
8 | import androidx.navigation.navArgument
9 | import com.google.accompanist.navigation.animation.composable
10 |
11 | private const val argsTopicId = "topicId"
12 | const val addSupplementNavigationRoute = "/append/topic/{$argsTopicId}"
13 |
14 | class AddSupplementArgs(val topicId: String) {
15 | constructor(savedStateHandle: SavedStateHandle) : this(
16 | checkNotNull(
17 | savedStateHandle.get(argsTopicId)
18 | )
19 | )
20 | }
21 |
22 | fun NavController.navigateToAddSupplement(topicId: String) {
23 | navigate("/append/topic/$topicId")
24 | }
25 |
26 | @OptIn(ExperimentalAnimationApi::class)
27 | fun NavGraphBuilder.addSupplementScreen(
28 | onCloseClick: () -> Unit,
29 | onAddSupplementSuccess: (String) -> Unit,
30 | openUri: (String) -> Unit,
31 | ) {
32 | composable(
33 | addSupplementNavigationRoute,
34 | arguments = listOf(navArgument(argsTopicId) { type = NavType.StringType })
35 | ) {
36 | AddSupplementScreenRoute(
37 | onCloseClick = onCloseClick,
38 | onAddSupplementSuccess = onAddSupplementSuccess,
39 | openUri = openUri
40 | )
41 | }
42 | }
43 |
44 |
--------------------------------------------------------------------------------
/app/src/main/java/io/github/v2compose/core/CheckInWorker.kt:
--------------------------------------------------------------------------------
1 | package io.github.v2compose.core
2 |
3 | import android.app.Notification
4 | import android.content.Context
5 | import android.util.Log
6 | import androidx.hilt.work.HiltWorker
7 | import androidx.work.CoroutineWorker
8 | import androidx.work.ForegroundInfo
9 | import androidx.work.WorkerParameters
10 | import dagger.assisted.Assisted
11 | import dagger.assisted.AssistedInject
12 | import io.github.v2compose.R
13 | import io.github.v2compose.usecase.CheckInUseCase
14 |
15 | private const val TAG = "CheckInWorker"
16 | private const val NotificationIdCheckIn: Int = 1001
17 |
18 | @HiltWorker
19 | class CheckInWorker @AssistedInject constructor(
20 | @Assisted appContext: Context,
21 | @Assisted workerParameters: WorkerParameters,
22 | private val checkIn: CheckInUseCase,
23 | ) : CoroutineWorker(appContext, workerParameters) {
24 |
25 | override suspend fun doWork(): Result {
26 | val result = checkIn()
27 | Log.d(TAG, "doWork, result = $result")
28 | return if (result.success) Result.success() else Result.retry()
29 | }
30 |
31 | override suspend fun getForegroundInfo(): ForegroundInfo {
32 | return ForegroundInfo(NotificationIdCheckIn, createNotification())
33 | }
34 |
35 | private fun createNotification(): Notification {
36 | return Notification.Builder(applicationContext, NotificationCenter.ChannelAutoCheckIn)
37 | .setContentTitle(applicationContext.getString(R.string.auto_checking_in))
38 | .build()
39 | }
40 |
41 | }
--------------------------------------------------------------------------------
/app/src/main/java/io/github/v2compose/ui/login/LoginScreenState.kt:
--------------------------------------------------------------------------------
1 | package io.github.v2compose.ui.login
2 |
3 | import android.content.Context
4 | import androidx.compose.runtime.*
5 | import androidx.compose.ui.platform.LocalContext
6 | import io.github.v2compose.R
7 |
8 | @Composable
9 | fun rememberLoginScreenState(context: Context = LocalContext.current): LoginScreenState {
10 | return remember(context) {
11 | LoginScreenState(context)
12 | }
13 | }
14 |
15 | @Stable
16 | class LoginScreenState(private val context: Context) {
17 |
18 | var userNameError by mutableStateOf("")
19 | private set
20 | var passwordError by mutableStateOf("")
21 | private set
22 | var captchaError by mutableStateOf("")
23 | private set
24 |
25 | fun checkValid(userName: String, password: String, captcha: String): Boolean {
26 | userNameError =
27 | if (userName.isBlank()) context.getString(R.string.login_username_blank) else ""
28 | passwordError =
29 | if (password.isBlank()) context.getString(R.string.login_password_blank) else ""
30 | captchaError =
31 | if (captcha.isBlank()) context.getString(R.string.login_captcha_blank) else ""
32 | return userNameError.isEmpty() && passwordError.isEmpty() && captchaError.isEmpty()
33 | }
34 |
35 | fun resetUserNameError() {
36 | userNameError = ""
37 | }
38 |
39 | fun resetPasswordError() {
40 | passwordError = ""
41 | }
42 |
43 | fun resetCaptchaError() {
44 | captchaError = ""
45 | }
46 |
47 | }
--------------------------------------------------------------------------------
/app/src/main/java/io/github/v2compose/datasource/AppStateStore.kt:
--------------------------------------------------------------------------------
1 | package io.github.v2compose.datasource
2 |
3 | import io.github.v2compose.network.bean.NewsInfo
4 | import io.github.v2compose.network.bean.NodesNavInfo
5 | import kotlinx.coroutines.flow.Flow
6 | import kotlinx.coroutines.flow.MutableStateFlow
7 | import kotlinx.coroutines.flow.emitAll
8 | import kotlinx.coroutines.flow.flow
9 | import me.ghui.fruit.Fruit
10 | import javax.inject.Inject
11 | import javax.inject.Singleton
12 |
13 | @Singleton
14 | class AppStateStore @Inject constructor(
15 | private val fruit: Fruit,
16 | ) {
17 |
18 | private val _hasCheckingInTips = MutableStateFlow(false)
19 | val hasCheckingInTips: Flow = flow {
20 | emitAll(_hasCheckingInTips)
21 | }
22 |
23 | suspend fun updateHasCheckingInTips(value: Boolean) {
24 | _hasCheckingInTips.emit(value)
25 | }
26 |
27 | private val _nodesNavInfo = MutableStateFlow(null)
28 | val nodesNavInfo: Flow = flow {
29 | emitAll(_nodesNavInfo)
30 | }
31 |
32 | suspend fun updateNodesNavInfoWithNewsInfo(newsInfo: NewsInfo) {
33 | if (newsInfo.isValid && _nodesNavInfo.value != null) {
34 | return
35 | }
36 | val newNodesNavInfo = fruit.fromHtml(newsInfo.rawResponse, NodesNavInfo::class.java)
37 | if (newNodesNavInfo.isValid) {
38 | _nodesNavInfo.emit(newNodesNavInfo)
39 | }
40 | }
41 |
42 | suspend fun updateNodesNavInfo(value: NodesNavInfo?) {
43 | _nodesNavInfo.emit(value)
44 | }
45 |
46 | }
--------------------------------------------------------------------------------
/app/src/main/java/io/github/v2compose/datasource/RecentTopicsPagingSource.kt:
--------------------------------------------------------------------------------
1 | package io.github.v2compose.datasource
2 |
3 | import androidx.paging.PagingSource
4 | import androidx.paging.PagingState
5 | import io.github.v2compose.network.V2exService
6 | import io.github.v2compose.network.bean.RecentTopics
7 |
8 | class RecentTopicsPagingSource(private val v2exService: V2exService) :
9 | PagingSource() {
10 |
11 | companion object {
12 | const val FIRST_PAGE: Int = 1
13 | }
14 |
15 | private val currentIds = mutableSetOf()
16 |
17 | override fun getRefreshKey(state: PagingState): Int? {
18 | return state.anchorPosition?.let { anchorPosition ->
19 | val anchorPage = state.closestPageToPosition(anchorPosition)
20 | anchorPage?.prevKey?.plus(1) ?: anchorPage?.nextKey?.minus(1)
21 | }
22 | }
23 |
24 | override suspend fun load(params: LoadParams): LoadResult {
25 | return try {
26 | val page = params.key ?: FIRST_PAGE
27 | val topics = v2exService.recentTopics(page)
28 | val prevKey = if (page <= FIRST_PAGE) null else page - 1
29 | val nextKey = if (page < topics.pageCount) page + 1 else null
30 | val data = topics.items.filterNot { currentIds.contains(it.id) }
31 | currentIds.addAll(data.map { it.id })
32 | LoadResult.Page(data, prevKey, nextKey)
33 | } catch (e: Exception) {
34 | e.printStackTrace()
35 | LoadResult.Error(e)
36 | }
37 | }
38 | }
--------------------------------------------------------------------------------
/app/src/main/java/io/github/v2compose/ui/main/composables/ClickHandler.kt:
--------------------------------------------------------------------------------
1 | package io.github.v2compose.ui.main.composables
2 |
3 | import androidx.compose.runtime.*
4 |
5 | @Composable
6 | fun ClickHandler(enabled: Boolean, onClick: () -> Unit) {
7 | val currentOnClick by rememberUpdatedState(newValue = onClick)
8 | val clickCallback = remember {
9 | object : ClickDispatcher.OnClickCallback(enabled) {
10 | override fun handleClick() {
11 | currentOnClick()
12 | }
13 | }
14 | }
15 | SideEffect {
16 | clickCallback.isEnabled = enabled
17 | }
18 | val clickDispatcher = checkNotNull(LocalClickDispatcher.current) {
19 | "No ClickDispatcher was provided via LocalClickDispatcherOwner"
20 | }
21 | DisposableEffect(clickDispatcher) {
22 | clickDispatcher.addCallback(clickCallback)
23 | onDispose {
24 | clickDispatcher.removeCallback(clickCallback)
25 | }
26 | }
27 | }
28 |
29 | val LocalClickDispatcher = compositionLocalOf { null }
30 |
31 | class ClickDispatcher {
32 |
33 | private val callbacks = mutableSetOf()
34 |
35 | fun addCallback(callback: OnClickCallback) {
36 | callbacks.add(callback)
37 | }
38 |
39 | fun removeCallback(callback: OnClickCallback) {
40 | callbacks.remove(callback)
41 | }
42 |
43 | fun dispatch() {
44 | callbacks.forEach { if (it.isEnabled) it.handleClick() }
45 | }
46 |
47 | abstract class OnClickCallback(var isEnabled: Boolean) {
48 | abstract fun handleClick()
49 | }
50 |
51 | }
52 |
53 |
--------------------------------------------------------------------------------
/app/src/main/java/io/github/v2compose/ui/main/mine/nodes/MyNodesViewModel.kt:
--------------------------------------------------------------------------------
1 | package io.github.v2compose.ui.main.mine.nodes
2 |
3 | import androidx.lifecycle.ViewModel
4 | import androidx.lifecycle.viewModelScope
5 | import dagger.hilt.android.lifecycle.HiltViewModel
6 | import io.github.v2compose.network.bean.MyNodesInfo
7 | import io.github.v2compose.repository.AccountRepository
8 | import kotlinx.coroutines.flow.MutableStateFlow
9 | import kotlinx.coroutines.flow.asStateFlow
10 | import kotlinx.coroutines.launch
11 | import javax.inject.Inject
12 |
13 | @HiltViewModel
14 | class MyNodesViewModel @Inject constructor(private val accountRepository: AccountRepository) :
15 | ViewModel() {
16 |
17 | private val _uiState = MutableStateFlow(MyNodesUiState.Loading)
18 | val uiState = _uiState.asStateFlow()
19 |
20 | init {
21 | loadMyNodes()
22 | }
23 |
24 | fun refresh() {
25 | loadMyNodes()
26 | }
27 |
28 | private fun loadMyNodes() {
29 | viewModelScope.launch {
30 | _uiState.emit(MyNodesUiState.Loading)
31 | try {
32 | val result = accountRepository.getMyNodes()
33 | _uiState.emit(MyNodesUiState.Success(result))
34 | } catch (e: Exception) {
35 | e.printStackTrace()
36 | _uiState.emit(MyNodesUiState.Error(e))
37 | }
38 | }
39 | }
40 |
41 | }
42 |
43 | sealed interface MyNodesUiState {
44 | object Loading : MyNodesUiState
45 | data class Success(val data: MyNodesInfo) : MyNodesUiState
46 | data class Error(val error: Throwable?) : MyNodesUiState
47 | }
--------------------------------------------------------------------------------
/app/src/main/res/values/themes.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
8 |
9 |
14 |
15 |
16 |
20 |
21 |
30 |
31 |
32 |
--------------------------------------------------------------------------------
/app/src/main/java/io/github/v2compose/util/WebViewProxy.kt:
--------------------------------------------------------------------------------
1 | package io.github.v2compose.util
2 |
3 | import android.util.Log
4 | import androidx.webkit.ProxyConfig
5 | import androidx.webkit.ProxyController
6 | import androidx.webkit.WebViewFeature
7 | import io.github.v2compose.bean.ProxyInfo
8 | import io.github.v2compose.bean.ProxyType
9 | import java.util.concurrent.ExecutorService
10 |
11 | private const val TAG = "WebViewProxy"
12 |
13 | object WebViewProxy {
14 |
15 | fun updateProxy(proxy: ProxyInfo, executorService: ExecutorService) {
16 | if (WebViewFeature.isFeatureSupported(WebViewFeature.PROXY_OVERRIDE)) {
17 | if (proxy.type == ProxyType.Http || proxy.type == ProxyType.Socks) {
18 | //https_proxy=http://127.0.0.1:7890 http_proxy=http://127.0.0.1:7890 all_proxy=socks5://127.0.0.1:7890
19 | val address = "${proxy.address}:${proxy.port}"
20 | val proxyConfig = ProxyConfig.Builder().apply {
21 | if (proxy.type == ProxyType.Http) {
22 | addProxyRule("http://$address")
23 | } else {
24 | addProxyRule("socks://$address")
25 | }
26 | }.build()
27 | ProxyController.getInstance().setProxyOverride(proxyConfig, executorService) {
28 | Log.d(TAG, "proxy has set, $proxy")
29 | }
30 | } else {
31 | ProxyController.getInstance().clearProxyOverride(executorService) {
32 | Log.d(TAG, "proxy has cleared, $proxy")
33 | }
34 | }
35 | }
36 | }
37 | }
--------------------------------------------------------------------------------
/app/src/main/java/io/github/v2compose/ui/search/SearchNavigation.kt:
--------------------------------------------------------------------------------
1 | package io.github.v2compose.ui.search
2 |
3 | import android.net.Uri
4 | import androidx.compose.animation.ExperimentalAnimationApi
5 | import androidx.lifecycle.SavedStateHandle
6 | import androidx.navigation.NavController
7 | import androidx.navigation.NavGraphBuilder
8 | import androidx.navigation.NavType
9 | import androidx.navigation.navArgument
10 | import com.google.accompanist.navigation.animation.composable
11 | import io.github.v2compose.core.StringDecoder
12 | import io.github.v2compose.network.bean.SoV2EXSearchResultInfo
13 |
14 | private const val argsKeyword = "keyword"
15 | const val searchScreenNavigationRoute = "/search?keyword={$argsKeyword}"
16 |
17 | data class SearchArgs(val keyword: String?) {
18 | constructor(savedStateHandle: SavedStateHandle, stringDecoder: StringDecoder) : this(
19 | savedStateHandle.get(argsKeyword)?.let { stringDecoder.decodeString(it) }
20 | )
21 | }
22 |
23 | fun NavController.navigateToSearch(keyword: String? = null) {
24 | val encodedKeyword = Uri.encode(keyword) ?: ""
25 | navigate("/search?keyword=$encodedKeyword")
26 | }
27 |
28 | @OptIn(ExperimentalAnimationApi::class)
29 | fun NavGraphBuilder.searchScreen(
30 | goBack: () -> Unit,
31 | onTopicClick: (SoV2EXSearchResultInfo.Hit) -> Unit
32 | ) {
33 | composable(
34 | route = searchScreenNavigationRoute,
35 | arguments = listOf(navArgument(argsKeyword) {
36 | type = NavType.StringType
37 | nullable = true
38 | })
39 | ) {
40 | SearchScreenRoute(goBack = goBack, onTopicClick = onTopicClick)
41 | }
42 | }
--------------------------------------------------------------------------------
/app/src/main/java/io/github/v2compose/bean/Account.kt:
--------------------------------------------------------------------------------
1 | package io.github.v2compose.bean
2 |
3 | import com.squareup.moshi.JsonClass
4 | import com.squareup.moshi.Moshi
5 | import com.squareup.moshi.adapter
6 |
7 | @JsonClass(generateAdapter = true)
8 | data class Account(
9 | val userName: String = "",
10 | val userAvatar: String = "",
11 | val description: String = "",
12 | val nodes: Int = 0,
13 | val topics: Int = 0,
14 | val following: Int = 0,
15 | val balance: AccountBalance = AccountBalance.Empty,
16 | ) {
17 |
18 | companion object {
19 | val Empty = Account()
20 |
21 | @OptIn(ExperimentalStdlibApi::class)
22 | fun fromJson(moshi: Moshi, json: String): Account {
23 | return moshi.adapter().fromJson(json) ?: Empty
24 | }
25 | }
26 |
27 | @OptIn(ExperimentalStdlibApi::class)
28 | fun toJson(moshi: Moshi): String {
29 | return moshi.adapter().toJson(this)
30 | }
31 |
32 | fun isValid(): Boolean {
33 | return userName.isNotEmpty()
34 | }
35 |
36 | }
37 |
38 | @JsonClass(generateAdapter = true)
39 | data class AccountBalance(val gold: Int = 0, val silver: Int = 0, val bronze: Int = 0) {
40 | companion object {
41 | val Empty = AccountBalance()
42 |
43 | @OptIn(ExperimentalStdlibApi::class)
44 | fun fromJson(moshi: Moshi, json: String): AccountBalance {
45 | return moshi.adapter().fromJson(json) ?: Empty
46 | }
47 | }
48 |
49 | @OptIn(ExperimentalStdlibApi::class)
50 | fun toJson(moshi: Moshi): String {
51 | return moshi.adapter().toJson(this)
52 | }
53 | }
--------------------------------------------------------------------------------
/app/src/main/java/io/github/v2compose/ui/main/notifications/NotificationViewModel.kt:
--------------------------------------------------------------------------------
1 | package io.github.v2compose.ui.main.notifications
2 |
3 | import androidx.compose.runtime.mutableStateMapOf
4 | import androidx.lifecycle.ViewModel
5 | import androidx.lifecycle.viewModelScope
6 | import androidx.paging.cachedIn
7 | import dagger.hilt.android.lifecycle.HiltViewModel
8 | import io.github.v2compose.repository.AccountRepository
9 | import io.github.v2compose.usecase.FixHtmlUseCase
10 | import kotlinx.coroutines.flow.SharingStarted
11 | import kotlinx.coroutines.flow.collectLatest
12 | import kotlinx.coroutines.flow.stateIn
13 | import kotlinx.coroutines.launch
14 | import javax.inject.Inject
15 |
16 | @HiltViewModel
17 | class NotificationViewModel @Inject constructor(
18 | private val accountRepository: AccountRepository,
19 | private val fixedHtmlImage: FixHtmlUseCase,
20 | ) : ViewModel() {
21 |
22 | val isLoggedIn = accountRepository.isLoggedIn
23 | .stateIn(
24 | viewModelScope,
25 | SharingStarted.WhileSubscribed(),
26 | false
27 | )
28 |
29 | val unreadNotifications = accountRepository.unreadNotifications.stateIn(
30 | scope = viewModelScope,
31 | started = SharingStarted.WhileSubscribed(),
32 | 0
33 | )
34 |
35 | val notifications = accountRepository.getNotifications().cachedIn(viewModelScope)
36 |
37 | val sizedHtmls = mutableStateMapOf()
38 |
39 | fun loadHtmlImage(tag: String, html: String, imageSrc: String?) {
40 | viewModelScope.launch {
41 | fixedHtmlImage.loadHtmlImages(html, imageSrc).collectLatest { sizedHtmls[tag] = it }
42 | }
43 | }
44 | }
--------------------------------------------------------------------------------
/app/src/main/java/io/github/v2compose/datasource/UserTopicsDataSource.kt:
--------------------------------------------------------------------------------
1 | package io.github.v2compose.datasource
2 |
3 | import androidx.paging.PagingSource
4 | import androidx.paging.PagingState
5 | import io.github.v2compose.core.error.VisibilityError
6 | import io.github.v2compose.network.V2exService
7 | import io.github.v2compose.network.bean.UserTopics
8 |
9 | class UserTopicsDataSource(private val userName: String, private val v2exService: V2exService) :
10 | PagingSource() {
11 |
12 | companion object {
13 | const val FIRST_PAGE: Int = 1
14 | }
15 |
16 | override fun getRefreshKey(state: PagingState): Int? {
17 | return state.anchorPosition?.let { anchorPosition ->
18 | val anchorPage = state.closestPageToPosition(anchorPosition)
19 | anchorPage?.prevKey?.plus(1) ?: anchorPage?.nextKey?.minus(1)
20 | }
21 | }
22 |
23 | override suspend fun load(params: LoadParams): LoadResult {
24 | return try {
25 | val page = params.key ?: FIRST_PAGE
26 | val userTopics = v2exService.userTopics(userName, page)
27 | val prevKey = if (page == FIRST_PAGE) null else page - 1
28 | val nextKey = if (page < userTopics.pageCount) page + 1 else null
29 | if (userTopics.visibility.isNotEmpty()) {
30 | LoadResult.Error(VisibilityError(userTopics.visibility))
31 | } else {
32 | LoadResult.Page(userTopics.items, prevKey, nextKey)
33 | }
34 | } catch (e: Exception) {
35 | e.printStackTrace()
36 | LoadResult.Error(e)
37 | }
38 | }
39 | }
--------------------------------------------------------------------------------
/app/src/main/java/io/github/v2compose/ui/settings/compoables/AutoCheckInPermissions.kt:
--------------------------------------------------------------------------------
1 | package io.github.v2compose.ui.settings.compoables
2 |
3 | import android.Manifest
4 | import android.app.Activity
5 | import android.content.Context
6 | import android.content.pm.PackageManager
7 | import android.os.Build
8 | import androidx.activity.compose.ManagedActivityResultLauncher
9 | import androidx.core.app.ActivityCompat
10 | import io.github.v2compose.core.NotificationCenter
11 |
12 |
13 | fun checkAndRequestNotificationPermission(
14 | context: Context,
15 | launcher: ManagedActivityResultLauncher,
16 | showRationale: () -> Unit,
17 | onDenied: () -> Unit,
18 | onGranted: () -> Unit,
19 | ) {
20 | val channelEnabled = NotificationCenter.isAutoCheckInChannelEnabled(context)
21 | if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
22 | if (channelEnabled) onGranted() else onDenied()
23 | return
24 | }
25 |
26 | val permission = Manifest.permission.POST_NOTIFICATIONS
27 | val permissionResult = ActivityCompat.checkSelfPermission(context, permission)
28 | if (permissionResult == PackageManager.PERMISSION_GRANTED) {
29 | if (channelEnabled) onGranted() else showRationale()
30 | } else {
31 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
32 | if (ActivityCompat.shouldShowRequestPermissionRationale(
33 | context as Activity,
34 | Manifest.permission.POST_NOTIFICATIONS,
35 | )
36 | ) {
37 | showRationale()
38 | return
39 | }
40 | }
41 | launcher.launch(permission)
42 | }
43 | }
--------------------------------------------------------------------------------
/app/src/main/java/io/github/v2compose/network/bean/NewUserBannedCreateInfo.java:
--------------------------------------------------------------------------------
1 | package io.github.v2compose.network.bean;
2 |
3 | import io.github.v2compose.util.Check;
4 | import me.ghui.fruit.Attrs;
5 | import me.ghui.fruit.annotations.Pick;
6 |
7 | /**
8 | * Created by ghui on 31/07/2017.
9 | */
10 |
11 | @Pick("div#Main")
12 | public class NewUserBannedCreateInfo extends BaseInfo {
13 | @Pick(value = "div.cell", attr = Attrs.INNER_HTML)
14 | private String errorInfo;
15 | @Pick("div.header")
16 | private String title;
17 | @Pick("strong#seconds")
18 | private int timeLeft;
19 |
20 | @Override
21 | public String toString() {
22 | return "NewUserBannedCreateInfo{" +
23 | "errorInfo='" + errorInfo + '\'' +
24 | ", title='" + title + '\'' +
25 | ", timeLeft='" + timeLeft + '\'' +
26 | '}';
27 | }
28 |
29 | public String getErrorInfo() {
30 | // return errorInfo;
31 | return "你的帐号刚刚注册,在你能够发帖之前,请先在 V2EX 浏览一下,了解一下这个社区的文化。\n" +
32 | "V2EX 是创意工作者的社区,这里能够帮助你解决问题及展示作品。关于这里的更多介绍,请点击去了解。\n"
33 | + "距离能够发帖还有 " + timeLeft + " 秒";
34 | }
35 |
36 | public void setErrorInfo(String errorInfo) {
37 | this.errorInfo = errorInfo;
38 | }
39 |
40 | public String getTitle() {
41 | // return title;
42 | return "请稍等";
43 | }
44 |
45 | public void setTitle(String title) {
46 | this.title = title;
47 | }
48 |
49 | @Override
50 | public boolean isValid() {
51 | return Check.notEmpty(errorInfo)
52 | && errorInfo.contains("你的帐号刚刚注册")
53 | && errorInfo.contains("距离能够发帖");
54 | }
55 | }
56 |
--------------------------------------------------------------------------------
/app/src/main/java/io/github/v2compose/usecase/UpdateAccountUseCase.kt:
--------------------------------------------------------------------------------
1 | package io.github.v2compose.usecase
2 |
3 | import android.net.Uri
4 | import io.github.v2compose.datasource.AccountPreferences
5 | import io.github.v2compose.network.bean.LoginResultInfo
6 | import io.github.v2compose.network.bean.NewsInfo
7 | import io.github.v2compose.repository.AccountRepository
8 | import kotlinx.coroutines.flow.first
9 | import me.ghui.fruit.Fruit
10 | import retrofit2.HttpException
11 | import javax.inject.Inject
12 |
13 | class UpdateAccountUseCase @Inject constructor(
14 | private val fruit: Fruit,
15 | private val accountPreferences: AccountPreferences,
16 | private val accountRepository: AccountRepository,
17 | ) {
18 |
19 | suspend fun updateWithNewsInfo(newsInfo: NewsInfo) {
20 | if (!accountRepository.isLoggedIn.first()) {
21 | return
22 | }
23 | val loginResultInfo: LoginResultInfo? =
24 | fruit.fromHtml(newsInfo.rawResponse, LoginResultInfo::class.java)
25 | if (loginResultInfo == null || !loginResultInfo.isValid) {
26 | return
27 | }
28 | accountPreferences.updateAccount(
29 | userName = loginResultInfo.userName,
30 | userAvatar = loginResultInfo.avatar,
31 | )
32 | }
33 |
34 | suspend fun updateWithException(e: Exception, userName: String) {
35 | if (e !is HttpException) return
36 | val resp = e.response()?.raw() ?: return
37 | if (!resp.isRedirect) return
38 | val location = resp.header("location") ?: return
39 | val uri = Uri.parse(location) ?: return
40 | if (uri.path == "/") {
41 | accountPreferences.updateAccount(userName = userName)
42 | }
43 | }
44 |
45 | }
--------------------------------------------------------------------------------
/app/src/main/java/io/github/v2compose/ui/common/HtmlComposables.kt:
--------------------------------------------------------------------------------
1 | package io.github.v2compose.ui.common
2 |
3 | import androidx.compose.material3.MaterialTheme
4 | import androidx.compose.runtime.Composable
5 | import androidx.compose.runtime.LaunchedEffect
6 | import androidx.compose.ui.Modifier
7 | import androidx.compose.ui.text.TextStyle
8 | import androidx.compose.ui.unit.sp
9 | import io.github.cooaer.htmltext.HtmlText
10 | import io.github.v2compose.Constants
11 |
12 | private const val TAG = "HtmlComposables"
13 |
14 | typealias OnHtmlImageClick = (String, List) -> Unit
15 |
16 | @Composable
17 | fun HtmlContent(
18 | content: String,
19 | modifier: Modifier = Modifier,
20 | selectable: Boolean = false,
21 | textStyle: TextStyle = MaterialTheme.typography.bodyMedium.copy(
22 | fontSize = 15.sp,
23 | lineHeight = 22.sp,
24 | letterSpacing = 0.3.sp,
25 | ),
26 | baseUrl: String = Constants.baseUrl,
27 | linkFloor: Boolean = false,
28 | onUriClick: ((uri: String) -> Unit)? = null,
29 | onClick: (() -> Unit)? = null,
30 | loadImage: ((html: String, img: String?) -> Unit)? = null,
31 | onHtmlImageClick: ((String, List) -> Unit)? = null
32 | ) {
33 |
34 | HtmlText(
35 | html = content,
36 | modifier = modifier,
37 | selectable = selectable,
38 | textStyle = textStyle,
39 | baseUrl = baseUrl,
40 | onLinkClick = onUriClick,
41 | onClick = onClick,
42 | loadImage = { src -> loadImage?.invoke(content, src) },
43 | onImageClick = { clicked, all -> onHtmlImageClick?.invoke(clicked.src, all.map { it.src }) }
44 | )
45 |
46 | LaunchedEffect(true) {
47 | loadImage?.invoke(content, null)
48 | }
49 | }
50 |
--------------------------------------------------------------------------------
/app/src/main/java/io/github/v2compose/core/Browser.kt:
--------------------------------------------------------------------------------
1 | package io.github.v2compose.core
2 |
3 | import android.content.Context
4 | import android.content.Intent
5 | import android.content.pm.PackageManager
6 | import android.content.pm.ResolveInfo
7 | import android.net.Uri
8 | import androidx.browser.customtabs.CustomTabsService.ACTION_CUSTOM_TABS_CONNECTION
9 |
10 |
11 | fun Context.getDefaultBrowser(): String? {
12 | val intent = Intent(Intent.ACTION_VIEW, Uri.parse("https://www.v2ex.com"))
13 | val resolveInfo = packageManager.resolveActivity(intent, PackageManager.MATCH_DEFAULT_ONLY)
14 | ?: return null
15 | val activityInfo = resolveInfo.activityInfo ?: return null
16 | return activityInfo.packageName
17 | }
18 |
19 | fun Context.getCustomTabsBrowsers(): List {
20 | // Get default VIEW intent handler.
21 | val activityIntent = Intent()
22 | .setAction(Intent.ACTION_VIEW)
23 | .addCategory(Intent.CATEGORY_BROWSABLE)
24 | .setData(Uri.fromParts("http", "", null))
25 |
26 | // Get all apps that can handle VIEW intents.
27 | val resolvedActivityList = packageManager.queryIntentActivities(activityIntent, 0)
28 | val packagesSupportingCustomTabs = ArrayList()
29 | for (info in resolvedActivityList) {
30 | val serviceIntent = Intent()
31 | serviceIntent.action = ACTION_CUSTOM_TABS_CONNECTION
32 | serviceIntent.setPackage(info.activityInfo.packageName)
33 | // Check if this package also resolves the Custom Tabs service.
34 | if (packageManager.resolveService(serviceIntent, 0) != null) {
35 | packagesSupportingCustomTabs.add(info)
36 | }
37 | }
38 | return packagesSupportingCustomTabs.map { it.activityInfo.packageName }
39 | }
--------------------------------------------------------------------------------
/app/src/main/java/io/github/v2compose/ui/node/NodeScreenState.kt:
--------------------------------------------------------------------------------
1 | package io.github.v2compose.ui.node
2 |
3 | import android.content.Context
4 | import androidx.compose.material3.SnackbarHostState
5 | import androidx.compose.runtime.Composable
6 | import androidx.compose.runtime.Stable
7 | import androidx.compose.runtime.remember
8 | import androidx.compose.runtime.rememberCoroutineScope
9 | import androidx.compose.ui.platform.LocalContext
10 | import io.github.v2compose.LocalSnackbarHostState
11 | import io.github.v2compose.core.share
12 | import io.github.v2compose.ui.BaseScreenState
13 | import io.github.v2compose.ui.BaseViewModel
14 | import kotlinx.coroutines.CoroutineScope
15 |
16 | @Composable
17 | fun rememberNodeScreenState(
18 | context: Context = LocalContext.current,
19 | coroutineScope: CoroutineScope = rememberCoroutineScope(),
20 | snackbarHostState: SnackbarHostState = LocalSnackbarHostState.current,
21 | ): NodeScreenState {
22 | return remember(context, coroutineScope, snackbarHostState) {
23 | NodeScreenState(context, coroutineScope, snackbarHostState)
24 | }
25 | }
26 |
27 | @Stable
28 | class NodeScreenState(
29 | context: Context,
30 | coroutineScope: CoroutineScope,
31 | snackbarHostState: SnackbarHostState
32 | ):BaseScreenState(context, coroutineScope, snackbarHostState) {
33 |
34 | fun share(nodeArgs: NodeArgs, nodeUiState: NodeUiState) {
35 | val title = if (nodeUiState is NodeUiState.Success) {
36 | "V2EX > " + nodeUiState.nodeInfo.name + "\n" + nodeUiState.nodeInfo.title
37 | } else {
38 | "V2EX > " + (nodeArgs.nodeTitle ?: "")
39 | }
40 | val url = "https://www.v2ex.com/go/${nodeArgs.nodeName}"
41 | context.share(title = title, url = url)
42 | }
43 |
44 | }
--------------------------------------------------------------------------------
/app/src/main/java/io/github/v2compose/repository/def/DefaultNewsRepository.kt:
--------------------------------------------------------------------------------
1 | package io.github.v2compose.repository.def
2 |
3 | import androidx.paging.Pager
4 | import androidx.paging.PagingConfig
5 | import androidx.paging.PagingData
6 | import io.github.v2compose.bean.AccountBalance
7 | import io.github.v2compose.datasource.AccountPreferences
8 | import io.github.v2compose.datasource.AppStateStore
9 | import io.github.v2compose.datasource.RecentTopicsPagingSource
10 | import io.github.v2compose.network.V2exService
11 | import io.github.v2compose.network.bean.NewsInfo
12 | import io.github.v2compose.network.bean.RecentTopics
13 | import io.github.v2compose.repository.NewsRepository
14 | import kotlinx.coroutines.flow.Flow
15 | import javax.inject.Inject
16 |
17 | class DefaultNewsRepository @Inject constructor(
18 | private val v2exService: V2exService,
19 | private val accountPreferences: AccountPreferences,
20 | private val appStateStore: AppStateStore,
21 | ) : NewsRepository {
22 |
23 | override suspend fun getHomeNews(tab: String): NewsInfo {
24 | return v2exService.homeNews(tab).also {
25 | accountPreferences.unreadNotifications(it.unreadCount)
26 | accountPreferences.updateAccount(
27 | balance = AccountBalance(
28 | it.balanceGold,
29 | it.balanceSilver,
30 | it.balanceBronze,
31 | )
32 | )
33 | appStateStore.updateHasCheckingInTips(it.hasCheckingInTips())
34 | appStateStore.updateNodesNavInfoWithNewsInfo(it)
35 | }
36 | }
37 |
38 | override val recentTopics: Flow>
39 | get() = Pager(PagingConfig(pageSize = 25)) { RecentTopicsPagingSource(v2exService) }.flow
40 |
41 | }
--------------------------------------------------------------------------------
/app/src/main/java/io/github/v2compose/datasource/NotificationsPagingSource.kt:
--------------------------------------------------------------------------------
1 | package io.github.v2compose.datasource
2 |
3 | import androidx.paging.PagingSource
4 | import androidx.paging.PagingState
5 | import io.github.v2compose.network.V2exService
6 | import io.github.v2compose.network.bean.NotificationInfo
7 | import kotlin.math.ceil
8 |
9 | class NotificationsPagingSource(
10 | private val v2exService: V2exService,
11 | private val accountPreferences: AccountPreferences,
12 | ) :
13 | PagingSource() {
14 |
15 | companion object {
16 | const val FirstPageIndex = 1
17 | const val ItemCountOfPage = 50
18 | }
19 |
20 | override fun getRefreshKey(state: PagingState): Int? {
21 | return state.anchorPosition?.let { anchorPosition ->
22 | val anchorPage = state.closestPageToPosition(anchorPosition)
23 | anchorPage?.prevKey?.plus(1) ?: anchorPage?.nextKey?.minus(1)
24 | }
25 | }
26 |
27 | override suspend fun load(params: LoadParams): LoadResult {
28 | val page = params.key ?: FirstPageIndex
29 | return try {
30 | val result = v2exService.notifications(page)
31 | accountPreferences.unreadNotifications(result.unreadCount)
32 |
33 | val pageCount = ceil(result.total.toFloat() / ItemCountOfPage).toInt()
34 | val prevKey = if (page > FirstPageIndex) page - 1 else null
35 | val nextKey = if (page < pageCount) page + 1 else null
36 | LoadResult.Page(data = result.replies, prevKey = prevKey, nextKey = nextKey)
37 | } catch (e: Exception) {
38 | e.printStackTrace()
39 | LoadResult.Error(e)
40 | }
41 |
42 | }
43 | }
--------------------------------------------------------------------------------
/app/src/main/java/io/github/v2compose/ui/main/mine/MineContentState.kt:
--------------------------------------------------------------------------------
1 | package io.github.v2compose.ui.main.mine
2 |
3 | import android.content.Context
4 | import androidx.compose.material3.SnackbarHostState
5 | import androidx.compose.runtime.Composable
6 | import androidx.compose.runtime.remember
7 | import androidx.compose.runtime.rememberCoroutineScope
8 | import androidx.compose.ui.platform.LocalContext
9 | import io.github.v2compose.LocalSnackbarHostState
10 | import io.github.v2compose.R
11 | import io.github.v2compose.bean.Account
12 | import io.github.v2compose.ui.BaseScreenState
13 | import kotlinx.coroutines.CoroutineScope
14 | import kotlinx.coroutines.launch
15 |
16 | @Composable
17 | fun rememberMineContentState(
18 | context: Context = LocalContext.current,
19 | coroutineScope: CoroutineScope = rememberCoroutineScope(),
20 | snackbarHostState: SnackbarHostState = LocalSnackbarHostState.current
21 | ): MineContentState {
22 | return remember(context, coroutineScope, snackbarHostState) {
23 | MineContentState(context, coroutineScope, snackbarHostState)
24 | }
25 | }
26 |
27 | class MineContentState(
28 | context: Context,
29 | coroutineScope: CoroutineScope,
30 | snackbarHostState: SnackbarHostState,
31 | ) : BaseScreenState(context, coroutineScope, snackbarHostState) {
32 |
33 | fun notImplemented() {
34 | coroutineScope.launch {
35 | val message = context.getString(R.string.function_not_implemented)
36 | snackbarHostState.showSnackbar(message = message)
37 | }
38 | }
39 |
40 | fun doActionIfLoggedIn(account: Account, action:() -> Unit){
41 | if (account.isValid()) {
42 | action()
43 | } else {
44 | showMessage(R.string.login_first)
45 | }
46 | }
47 |
48 | }
--------------------------------------------------------------------------------
/app/src/main/java/io/github/v2compose/ui/common/AutoFillModifier.kt:
--------------------------------------------------------------------------------
1 | package io.github.v2compose.ui.common
2 |
3 | import android.util.Log
4 | import androidx.compose.runtime.DisposableEffect
5 | import androidx.compose.runtime.remember
6 | import androidx.compose.ui.ExperimentalComposeUiApi
7 | import androidx.compose.ui.Modifier
8 | import androidx.compose.ui.autofill.AutofillNode
9 | import androidx.compose.ui.autofill.AutofillType
10 | import androidx.compose.ui.composed
11 | import androidx.compose.ui.focus.onFocusChanged
12 | import androidx.compose.ui.layout.boundsInWindow
13 | import androidx.compose.ui.layout.onGloballyPositioned
14 | import androidx.compose.ui.platform.LocalAutofill
15 | import androidx.compose.ui.platform.LocalAutofillTree
16 |
17 | private const val TAG = "AutoFillModifier"
18 |
19 | @OptIn(ExperimentalComposeUiApi::class)
20 | fun Modifier.autofill(
21 | autofillTypes: List,
22 | onFill: ((String) -> Unit),
23 | ) = composed {
24 | val autofill = LocalAutofill.current
25 | val autofillTree = LocalAutofillTree.current
26 |
27 | val autofillNode = remember {
28 | AutofillNode(autofillTypes = autofillTypes, onFill = onFill)
29 | }
30 |
31 | DisposableEffect(autofillNode) {
32 | autofillTree += autofillNode
33 | onDispose {
34 | autofillTree.children.remove(autofillNode.id)
35 | }
36 | }
37 |
38 | onGloballyPositioned {
39 | autofillNode.boundingBox = it.boundsInWindow()
40 | Log.d(TAG, "autofill, onGloballyPositioned, boundsInWindow = ${it.boundsInWindow()}")
41 | }.onFocusChanged { focusState ->
42 | autofill?.run {
43 | if (focusState.isFocused) {
44 | requestAutofillForNode(autofillNode)
45 | } else {
46 | cancelAutofillForNode(autofillNode)
47 | }
48 | }
49 | }
50 | }
--------------------------------------------------------------------------------
/htmlText/src/main/java/io/github/cooaer/htmltext/ClickableText.kt:
--------------------------------------------------------------------------------
1 | package io.github.cooaer.htmltext
2 |
3 | import androidx.compose.foundation.gestures.detectTapGestures
4 | import androidx.compose.foundation.text.InlineTextContent
5 | import androidx.compose.material3.Text
6 | import androidx.compose.runtime.Composable
7 | import androidx.compose.runtime.mutableStateOf
8 | import androidx.compose.runtime.remember
9 | import androidx.compose.ui.Modifier
10 | import androidx.compose.ui.input.pointer.pointerInput
11 | import androidx.compose.ui.text.AnnotatedString
12 | import androidx.compose.ui.text.TextLayoutResult
13 | import androidx.compose.ui.text.TextStyle
14 | import androidx.compose.ui.text.style.TextOverflow
15 |
16 | @Composable
17 | internal fun ClickableText(
18 | text: AnnotatedString,
19 | modifier: Modifier = Modifier,
20 | style: TextStyle = TextStyle.Default,
21 | softWrap: Boolean = true,
22 | overflow: TextOverflow = TextOverflow.Clip,
23 | maxLines: Int = Int.MAX_VALUE,
24 | inlineContent: Map = mapOf(),
25 | onTextLayout: (TextLayoutResult) -> Unit = {},
26 | onClick: (Int) -> Unit
27 | ) {
28 | val layoutResult = remember { mutableStateOf(null) }
29 | val pressIndicator = Modifier.pointerInput(onClick) {
30 | detectTapGestures { pos ->
31 | layoutResult.value?.let { layoutResult ->
32 | onClick(layoutResult.getOffsetForPosition(pos))
33 | }
34 | }
35 | }
36 |
37 | Text(
38 | text = text,
39 | modifier = modifier.then(pressIndicator),
40 | style = style,
41 | softWrap = softWrap,
42 | overflow = overflow,
43 | maxLines = maxLines,
44 | inlineContent = inlineContent,
45 | onTextLayout = {
46 | layoutResult.value = it
47 | onTextLayout(it)
48 | }
49 | )
50 | }
--------------------------------------------------------------------------------
/app/src/main/java/io/github/v2compose/ui/gallery/GalleryNavigation.kt:
--------------------------------------------------------------------------------
1 | package io.github.v2compose.ui.gallery
2 |
3 | import android.net.Uri
4 | import androidx.compose.animation.ExperimentalAnimationApi
5 | import androidx.lifecycle.SavedStateHandle
6 | import androidx.navigation.NavController
7 | import androidx.navigation.NavGraphBuilder
8 | import androidx.navigation.NavType
9 | import androidx.navigation.navArgument
10 | import com.google.accompanist.navigation.animation.composable
11 | import io.github.v2compose.core.StringDecoder
12 |
13 | private const val argsCurrent = "current"
14 | private const val argsPics = "pics"
15 | const val galleryNavigationRoute = "/gallery?$argsCurrent={$argsCurrent}&$argsPics={$argsPics}"
16 |
17 | data class GalleryScreenArgs(val current: String, val pics: List) {
18 | constructor(savedStateHandle: SavedStateHandle, stringDecoder: StringDecoder) :
19 | this(
20 | stringDecoder.decodeString(checkNotNull(savedStateHandle[argsCurrent])),
21 | checkNotNull(savedStateHandle.get(argsPics)).split(",").map {
22 | stringDecoder.decodeString(it)
23 | },
24 | )
25 | }
26 |
27 | fun NavController.navigateToGallery(current: String, pics: List) {
28 | val encodedPics = pics.joinToString(separator = ",") { Uri.encode(it) }
29 | val route = "/gallery?$argsCurrent=${Uri.encode(current)}&$argsPics=$encodedPics"
30 | navigate(route)
31 | }
32 |
33 |
34 | @OptIn(ExperimentalAnimationApi::class)
35 | fun NavGraphBuilder.galleryScreen(onBackClick: () -> Unit) {
36 | composable(
37 | galleryNavigationRoute,
38 | arguments = listOf(
39 | navArgument(argsCurrent) { type = NavType.StringType },
40 | navArgument(argsPics) { type = NavType.StringType },
41 | )
42 | ) {
43 | GalleryScreenRoute(onBackClick = onBackClick)
44 | }
45 | }
--------------------------------------------------------------------------------
/app/src/main/java/io/github/v2compose/core/NotificationCenter.kt:
--------------------------------------------------------------------------------
1 | package io.github.v2compose.core
2 |
3 | import android.app.NotificationChannel
4 | import android.app.NotificationManager
5 | import android.content.Context
6 | import android.content.Context.NOTIFICATION_SERVICE
7 | import androidx.core.app.NotificationManagerCompat
8 | import io.github.v2compose.R
9 |
10 |
11 | object NotificationCenter {
12 |
13 | const val ChannelAutoCheckIn = "autoCheckIn"
14 |
15 | fun init(context: Context) {
16 | val manager = context.getSystemService(NOTIFICATION_SERVICE) as NotificationManager?
17 | ?: return
18 | manager.createNotificationChannel(
19 | ChannelAutoCheckIn,
20 | context.getString(R.string.notification_channel_auto_check_in),
21 | NotificationManager.IMPORTANCE_DEFAULT
22 | )
23 | }
24 |
25 | fun isAutoCheckInChannelEnabled(context: Context): Boolean {
26 | return checkNotificationsEnabled(context) &&
27 | checkNotificationChannelEnabled(context, ChannelAutoCheckIn)
28 | }
29 |
30 | fun checkNotificationsEnabled(context: Context): Boolean {
31 | return NotificationManagerCompat.from(context).areNotificationsEnabled()
32 | }
33 |
34 | fun checkNotificationChannelEnabled(context: Context, channelID: String): Boolean {
35 | val manager = context.getSystemService(NOTIFICATION_SERVICE) as NotificationManager?
36 | ?: return false
37 | val channel = manager.getNotificationChannel(channelID)
38 | return channel.importance != NotificationManager.IMPORTANCE_NONE
39 | }
40 |
41 | }
42 |
43 | fun NotificationManager.createNotificationChannel(
44 | channelId: String,
45 | channelName: String,
46 | importance: Int
47 | ) {
48 | val autoCheckInChannel = NotificationChannel(channelId, channelName, importance)
49 | createNotificationChannel(autoCheckInChannel)
50 | }
51 |
--------------------------------------------------------------------------------
/app/src/main/java/io/github/v2compose/network/bean/TwoStepLoginInfo.java:
--------------------------------------------------------------------------------
1 | package io.github.v2compose.network.bean;
2 |
3 | import io.github.v2compose.util.Check;
4 | import me.ghui.fruit.Attrs;
5 | import me.ghui.fruit.annotations.Pick;
6 |
7 | /**
8 | * Created by ghui on 16/08/2017.
9 | */
10 | public class TwoStepLoginInfo extends BaseInfo {
11 |
12 | @Pick(value = "[href^=/member]", attr = "href")
13 | private String userLink;
14 | @Pick(value = "img[src*=avatar/]", attr = "src")
15 | private String avatar;
16 | @Pick(value = "div.problem", attr = Attrs.INNER_HTML)
17 | private String problem;
18 | @Pick("tr:first-child")
19 | private String title;
20 | @Pick(value = "input[type=hidden]", attr = "value")
21 | private String once;
22 |
23 | public String getUserName() {
24 | if (Check.isEmpty(userLink)) {
25 | return null;
26 | }
27 | return userLink.split("/")[2];
28 | }
29 |
30 | public String getAvatar() {
31 | if (Check.isEmpty(avatar)) return null;
32 | return avatar.replace("normal.png", "large.png");
33 | }
34 |
35 | public String getProblem() {
36 | return problem != null ? problem : "";
37 | }
38 |
39 | public String getTitle() {
40 | return title != null ? title : "";
41 | }
42 |
43 | public String getOnce() {
44 | return once;
45 | }
46 |
47 | @Override
48 | public boolean isValid() {
49 | return Check.notEmpty(avatar) && Check.notEmpty(avatar) && Check.notEmpty(once) && Check.notEmpty(title) && title.contains("两步验证");
50 | }
51 |
52 | @Override
53 | public String toString() {
54 | return "TwoStepLoginInfo{" +
55 | "userLink='" + userLink + '\'' +
56 | ", avatar='" + avatar + '\'' +
57 | ", problem='" + problem + '\'' +
58 | ", title='" + title + '\'' +
59 | ", once='" + once + '\'' +
60 | '}';
61 | }
62 | }
63 |
--------------------------------------------------------------------------------
/app/src/main/java/io/github/v2compose/ui/common/SimpleNode.kt:
--------------------------------------------------------------------------------
1 | package io.github.v2compose.ui.common
2 |
3 | import androidx.compose.foundation.background
4 | import androidx.compose.foundation.clickable
5 | import androidx.compose.foundation.layout.*
6 | import androidx.compose.foundation.shape.CircleShape
7 | import androidx.compose.material3.MaterialTheme
8 | import androidx.compose.material3.Text
9 | import androidx.compose.runtime.Composable
10 | import androidx.compose.ui.Alignment
11 | import androidx.compose.ui.Modifier
12 | import androidx.compose.ui.draw.clip
13 | import androidx.compose.ui.layout.ContentScale
14 | import androidx.compose.ui.text.style.TextAlign
15 | import androidx.compose.ui.text.style.TextOverflow
16 | import androidx.compose.ui.unit.dp
17 | import coil.compose.AsyncImage
18 | import io.github.cooaer.htmltext.fullUrl
19 | import io.github.v2compose.Constants
20 |
21 | @Composable
22 | fun SimpleNode(
23 | title: String,
24 | avatar: String,
25 | onItemClick: () -> Unit,
26 | modifier: Modifier = Modifier
27 | ) {
28 | Column(
29 | modifier = modifier
30 | .clickable { onItemClick() }
31 | .padding(horizontal = 2.dp, vertical = 12.dp),
32 | verticalArrangement = Arrangement.Center,
33 | horizontalAlignment = Alignment.CenterHorizontally,
34 | ) {
35 | AsyncImage(
36 | model = avatar.fullUrl(baseUrl = Constants.baseUrl),
37 | contentDescription = title,
38 | modifier = Modifier
39 | .size(40.dp)
40 | .clip(CircleShape)
41 | .background(color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.1f)),
42 | contentScale = ContentScale.Crop,
43 | )
44 | Spacer(Modifier.height(8.dp))
45 | Text(
46 | title,
47 | style = MaterialTheme.typography.bodyMedium,
48 | textAlign = TextAlign.Center,
49 | maxLines = 1,
50 | overflow = TextOverflow.Ellipsis,
51 | )
52 | }
53 | }
--------------------------------------------------------------------------------
/app/src/main/java/io/github/v2compose/ui/write/WriteTopicScreenState.kt:
--------------------------------------------------------------------------------
1 | package io.github.v2compose.ui.write
2 |
3 | import android.content.Context
4 | import androidx.annotation.StringRes
5 | import androidx.compose.material3.SnackbarDuration
6 | import androidx.compose.material3.SnackbarHostState
7 | import androidx.compose.runtime.Composable
8 | import androidx.compose.runtime.Stable
9 | import androidx.compose.runtime.remember
10 | import androidx.compose.runtime.rememberCoroutineScope
11 | import androidx.compose.ui.platform.LocalContext
12 | import io.github.v2compose.R
13 | import io.github.v2compose.network.bean.TopicNode
14 | import kotlinx.coroutines.CoroutineScope
15 | import kotlinx.coroutines.launch
16 |
17 | @Composable
18 | fun rememberWriteTopicScreenState(
19 | context: Context = LocalContext.current,
20 | coroutineScope: CoroutineScope = rememberCoroutineScope(),
21 | snackbarHostState: SnackbarHostState = remember { SnackbarHostState() },
22 | ): WriteTopicScreenState {
23 | return remember(context, coroutineScope, snackbarHostState) {
24 | WriteTopicScreenState(context, coroutineScope, snackbarHostState)
25 | }
26 | }
27 |
28 | @Stable
29 | class WriteTopicScreenState(
30 | private val context: Context,
31 | private val coroutineScope: CoroutineScope,
32 | val snackbarHostState: SnackbarHostState
33 | ) {
34 |
35 | fun check(title: String, content: String, node: TopicNode?): Boolean {
36 | if (title.isEmpty()) {
37 | showMessage(R.string.topic_title_empty)
38 | return false
39 | }
40 | if (node?.name.isNullOrEmpty()) {
41 | showMessage(R.string.node_empty)
42 | return false
43 | }
44 | return true
45 | }
46 |
47 | fun showMessage(@StringRes messageResId: Int) {
48 | showMessage(context.getString(messageResId))
49 | }
50 |
51 | fun showMessage(message: String) {
52 | coroutineScope.launch {
53 | snackbarHostState.showSnackbar(message = message, duration = SnackbarDuration.Short)
54 | }
55 | }
56 |
57 |
58 | }
--------------------------------------------------------------------------------
/app/src/main/java/io/github/v2compose/ui/write/WriteTopicNavigation.kt:
--------------------------------------------------------------------------------
1 | package io.github.v2compose.ui.write
2 |
3 | import android.net.Uri
4 | import androidx.compose.animation.ExperimentalAnimationApi
5 | import androidx.lifecycle.SavedStateHandle
6 | import androidx.navigation.NavController
7 | import androidx.navigation.NavGraphBuilder
8 | import androidx.navigation.NavType
9 | import androidx.navigation.navArgument
10 | import com.google.accompanist.navigation.animation.composable
11 | import io.github.v2compose.core.StringDecoder
12 |
13 | private const val argsNode = "node"
14 | private const val argsNodeTitle = "node_title"
15 | const val createTopicNavigationRoute =
16 | "/write?$argsNode={$argsNode}&$argsNodeTitle={$argsNodeTitle}"
17 |
18 | data class WriteTopicArgs(val nodeName: String?, val nodeTitle: String?) {
19 | constructor(savedStateHandle: SavedStateHandle, stringDecoder: StringDecoder) : this(
20 | savedStateHandle.get(argsNode)?.let { stringDecoder.decodeString(it) },
21 | savedStateHandle.get(argsNodeTitle)?.let { stringDecoder.decodeString(it) },
22 | )
23 | }
24 |
25 | fun NavController.navigateToWriteTopic(node: String? = null, nodeTitle: String? = null) {
26 | val encodedNode = Uri.encode(node) ?: ""
27 | val encodedNodeTitle = Uri.encode(nodeTitle) ?: ""
28 | navigate("/write?$argsNode=$encodedNode&$argsNodeTitle=$encodedNodeTitle")
29 | }
30 |
31 | @OptIn(ExperimentalAnimationApi::class)
32 | fun NavGraphBuilder.writeTopicScreen(
33 | onCloseClick: () -> Unit,
34 | openUri: (String) -> Unit,
35 | onCreateTopicSuccess: (topicId: String) -> Unit,
36 | ) {
37 | composable(
38 | route = createTopicNavigationRoute,
39 | arguments = listOf(
40 | navArgument(argsNode) { type = NavType.StringType },
41 | navArgument(argsNodeTitle) { type = NavType.StringType },
42 | )
43 | ) {
44 | WriteTopicScreenRoute(
45 | onCloseClick = onCloseClick,
46 | openUri = openUri,
47 | onCreateTopicSuccess = onCreateTopicSuccess
48 | )
49 | }
50 | }
--------------------------------------------------------------------------------
/app/src/main/java/io/github/v2compose/core/NavigationWithAnimation.kt:
--------------------------------------------------------------------------------
1 | package io.github.v2compose.core
2 |
3 | import androidx.annotation.AnimRes
4 | import androidx.compose.animation.*
5 | import androidx.compose.runtime.Composable
6 | import androidx.navigation.*
7 | import com.google.accompanist.navigation.animation.composable
8 | import io.github.v2compose.R
9 |
10 | fun NavController.navigateWithAnimation(
11 | route: String,
12 | @AnimRes enterAnim: Int = R.anim.slide_in_right,
13 | @AnimRes exitAnim: Int = R.anim.slide_out_left,
14 | @AnimRes popEnterAnim: Int = android.R.anim.slide_in_left,
15 | @AnimRes popExitAnim: Int = android.R.anim.slide_out_right,
16 | ) {
17 | navigate(
18 | route,
19 | NavOptions.Builder().apply {
20 | setEnterAnim(enterAnim)
21 | setExitAnim(exitAnim)
22 | setPopEnterAnim(popEnterAnim)
23 | setPopExitAnim(popExitAnim)
24 | }.build(),
25 | )
26 | }
27 |
28 | @OptIn(ExperimentalAnimationApi::class)
29 | fun NavGraphBuilder.composableWithAnimation(
30 | route: String,
31 | arguments: List = emptyList(),
32 | deepLinks: List = emptyList(),
33 | enterTransition: (AnimatedContentScope.() -> EnterTransition?)? = { slideInHorizontally { it } },
34 | exitTransition: (AnimatedContentScope.() -> ExitTransition?)? = { slideOutHorizontally { -it } },
35 | popEnterTransition: (AnimatedContentScope.() -> EnterTransition?)? = { slideInHorizontally { -it } },
36 | popExitTransition: (AnimatedContentScope.() -> ExitTransition?)? = { slideOutHorizontally { it } },
37 | content: @Composable AnimatedVisibilityScope.(NavBackStackEntry) -> Unit
38 | ) {
39 | composable(
40 | route = route,
41 | arguments = arguments,
42 | deepLinks = deepLinks,
43 | enterTransition = enterTransition,
44 | exitTransition = exitTransition,
45 | popEnterTransition = popEnterTransition,
46 | popExitTransition = popExitTransition,
47 | content = content
48 | )
49 | }
--------------------------------------------------------------------------------
/app/src/main/java/io/github/v2compose/ui/topic/TopicNavigation.kt:
--------------------------------------------------------------------------------
1 | package io.github.v2compose.ui.topic
2 |
3 | import android.net.Uri
4 | import androidx.compose.animation.ExperimentalAnimationApi
5 | import androidx.lifecycle.SavedStateHandle
6 | import androidx.navigation.*
7 | import com.google.accompanist.navigation.animation.composable
8 | import io.github.v2compose.core.StringDecoder
9 | import io.github.v2compose.ui.common.OnHtmlImageClick
10 |
11 | private const val argsTopicId: String = "topicId"
12 | private const val argsReplyFloor: String = "floor"
13 |
14 | const val topicNavigationRoute = "/t/{$argsTopicId}#reply{$argsReplyFloor}"
15 |
16 | data class TopicArgs(val topicId: String, val replyFloor: Int) {
17 | constructor(savedStateHandle: SavedStateHandle, stringDecoder: StringDecoder) : this(
18 | stringDecoder.decodeString(checkNotNull(savedStateHandle[argsTopicId])),
19 | checkNotNull(savedStateHandle[argsReplyFloor]),
20 | )
21 | }
22 |
23 | fun NavController.navigateToTopic(
24 | topicId: String,
25 | replyFloor: Int = 0,
26 | navOptions: NavOptions? = null
27 | ) {
28 | val encodedTopicId = Uri.encode(topicId)
29 | navigate("/t/$encodedTopicId#reply$replyFloor", navOptions)
30 | }
31 |
32 | @OptIn(ExperimentalAnimationApi::class)
33 | fun NavGraphBuilder.topicScreen(
34 | onBackClick: () -> Unit,
35 | onNodeClick: (String, String) -> Unit,
36 | onUserAvatarClick: (String, String) -> Unit,
37 | openUri: (String) -> Unit,
38 | onAddSupplementClick: (String) -> Unit,
39 | onHtmlImageClick: OnHtmlImageClick,
40 | ) {
41 | composable(
42 | topicNavigationRoute,
43 | arguments = listOf(
44 | navArgument(argsTopicId) { type = NavType.StringType },
45 | navArgument(argsReplyFloor) { type = NavType.IntType },
46 | )
47 | ) {
48 | TopicScreenRoute(
49 | onBackClick = onBackClick,
50 | onNodeClick = onNodeClick,
51 | onUserAvatarClick = onUserAvatarClick,
52 | openUri = openUri,
53 | onAddSupplementClick = onAddSupplementClick,
54 | onHtmlImageClick = onHtmlImageClick,
55 | )
56 | }
57 | }
--------------------------------------------------------------------------------
/app/src/main/java/io/github/v2compose/ui/main/MainNavigation.kt:
--------------------------------------------------------------------------------
1 | package io.github.v2compose.ui.main
2 |
3 | import androidx.compose.animation.ExperimentalAnimationApi
4 | import androidx.navigation.NavController
5 | import androidx.navigation.NavGraphBuilder
6 | import com.google.accompanist.navigation.animation.composable
7 | import io.github.v2compose.network.bean.NewsInfo
8 | import io.github.v2compose.network.bean.RecentTopics
9 | import io.github.v2compose.ui.common.OnHtmlImageClick
10 |
11 | const val mainNavigationRoute = "/"
12 |
13 | fun NavController.navigateToMain() {
14 | navigate(mainNavigationRoute) {
15 | popUpTo(mainNavigationRoute) {
16 | inclusive = true
17 | }
18 | }
19 | }
20 |
21 | @OptIn(ExperimentalAnimationApi::class)
22 | fun NavGraphBuilder.mainScreen(
23 | onNewsItemClick: (NewsInfo.Item) -> Unit,
24 | onRecentItemClick: (RecentTopics.Item) -> Unit,
25 | onNodeClick: (String, String) -> Unit,
26 | onUserAvatarClick: (String, String) -> Unit,
27 | onSearchClick: () -> Unit,
28 | onLoginClick: () -> Unit,
29 | onMyHomePageClick: () -> Unit,
30 | onCreateTopicClick: () -> Unit,
31 | onMyNodesClick: () -> Unit,
32 | onMyTopicsClick: () -> Unit,
33 | onMyFollowingClick: () -> Unit,
34 | onSettingsClick: () -> Unit,
35 | openUri: (String) -> Unit,
36 | onHtmlImageClick: OnHtmlImageClick,
37 | ) {
38 | composable(route = mainNavigationRoute) {
39 | MainScreenRoute(
40 | onNewsItemClick = onNewsItemClick,
41 | onRecentItemClick = onRecentItemClick,
42 | onNodeClick = onNodeClick,
43 | onUserAvatarClick = onUserAvatarClick,
44 | onSearchClick = onSearchClick,
45 | onLoginClick = onLoginClick,
46 | onMyHomePageClick = onMyHomePageClick,
47 | onCreateTopicClick = onCreateTopicClick,
48 | onMyNodesClick = onMyNodesClick,
49 | onMyTopicsClick = onMyTopicsClick,
50 | onMyFollowingClick = onMyFollowingClick,
51 | onSettingsClick = onSettingsClick,
52 | openUri = openUri,
53 | onHtmlImageClick = onHtmlImageClick,
54 | )
55 | }
56 | }
--------------------------------------------------------------------------------
/app/src/main/java/io/github/v2compose/network/bean/UserTopics.kt:
--------------------------------------------------------------------------------
1 | package io.github.v2compose.network.bean
2 |
3 | import me.ghui.fruit.Attrs
4 | import me.ghui.fruit.annotations.Pick
5 |
6 | @Pick("div#Wrapper")
7 | class UserTopics : BaseInfo() {
8 | @Pick("div.header strong.gray")
9 | val total: Int = -1
10 |
11 | @Pick("div.box div.cell.item")
12 | val items: List- = listOf()
13 |
14 | @Pick("div.inner:last-child strong.fade")
15 | private val pageInfo: String = ""
16 |
17 | @Pick("div.cell .topic_content")
18 | val visibility: String = ""
19 |
20 | val currentPage: Int
21 | get() {
22 | return pageInfo.split("/").getOrNull(0)?.toIntOrNull() ?: -1
23 | }
24 |
25 | val pageCount: Int
26 | get() {
27 | return pageInfo.split("/").getOrNull(1)?.toIntOrNull() ?: -1
28 | }
29 |
30 | override fun isValid(): Boolean {
31 | return total >= 0
32 | }
33 |
34 | override fun toString(): String {
35 | return "UserTopics(" +
36 | "total=$total, " +
37 | "items=$items, " +
38 | "pageInfo='$pageInfo', " +
39 | "currentPage=$currentPage, " +
40 | "pageCount=$pageCount" +
41 | ")"
42 | }
43 |
44 |
45 | class Item {
46 |
47 | @Pick(value = "span.item_title a", attr = Attrs.HREF)
48 | val link: String = ""
49 |
50 | @Pick("strong > a[href^=/member/]:first-child")
51 | val userName: String = ""
52 |
53 | @Pick("span.item_title")
54 | val title: String = ""
55 |
56 | @Pick(value = "a.node", attr = Attrs.HREF)
57 | val nodeLink: String = ""
58 |
59 | @Pick("a.node")
60 | val nodeTitle: String = ""
61 |
62 | @Pick("span.small.fade:last-child")
63 | val lastReply: String = ""
64 |
65 | @Pick("a[class^=count_]")
66 | val repliesNum: Int = 0
67 |
68 | override fun toString(): String {
69 | return "Item(link='$link', userName='$userName', title='$title', nodeLink='$nodeLink', nodeTitle='$nodeTitle', lastReply='$lastReply', repliesNum=$repliesNum)"
70 | }
71 |
72 | }
73 |
74 | }
--------------------------------------------------------------------------------
/app/src/main/java/io/github/v2compose/repository/def/DefaultUserRepository.kt:
--------------------------------------------------------------------------------
1 | package io.github.v2compose.repository.def
2 |
3 | import androidx.paging.Pager
4 | import androidx.paging.PagingConfig
5 | import androidx.paging.PagingData
6 | import io.github.v2compose.core.extension.isRedirect
7 | import io.github.v2compose.datasource.UserRepliesDataSource
8 | import io.github.v2compose.datasource.UserTopicsDataSource
9 | import io.github.v2compose.network.V2exService
10 | import io.github.v2compose.network.bean.UserPageInfo
11 | import io.github.v2compose.network.bean.UserReplies
12 | import io.github.v2compose.network.bean.UserTopics
13 | import io.github.v2compose.repository.UserRepository
14 | import io.github.v2compose.V2exUri
15 | import kotlinx.coroutines.flow.Flow
16 | import javax.inject.Inject
17 |
18 | class DefaultUserRepository @Inject constructor(private val v2exService: V2exService) :
19 | UserRepository {
20 | override suspend fun getUserPageInfo(userName: String): UserPageInfo {
21 | return v2exService.userPageInfo(userName)
22 | }
23 |
24 | override fun getUserTopics(userName: String): Flow> {
25 | return Pager(
26 | PagingConfig(
27 | pageSize = 20,
28 | enablePlaceholders = false
29 | )
30 | ) { UserTopicsDataSource(userName, v2exService) }.flow
31 | }
32 |
33 | override fun getUserReplies(userName: String): Flow> {
34 | return Pager(
35 | PagingConfig(
36 | pageSize = 20,
37 | enablePlaceholders = false
38 | )
39 | ) { UserRepliesDataSource(userName, v2exService) }.flow
40 | }
41 |
42 | override suspend fun doUserAction(userName: String, url: String): UserPageInfo {
43 | return try {
44 | val referer = V2exUri.userUrl(userName)
45 | v2exService.userAction(referer, url)
46 | } catch (e: Exception) {
47 | e.printStackTrace()
48 | if (e.isRedirect("/member/$userName")) {
49 | v2exService.userPageInfo(userName)
50 | } else {
51 | throw e
52 | }
53 | }
54 | }
55 | }
--------------------------------------------------------------------------------
/app/src/main/java/io/github/v2compose/datasource/TopicPagingSource.kt:
--------------------------------------------------------------------------------
1 | package io.github.v2compose.datasource
2 |
3 | import android.util.Log
4 | import androidx.paging.PagingSource
5 | import androidx.paging.PagingState
6 | import io.github.v2compose.network.V2exService
7 |
8 | private const val TAG = "TopicPagingSource"
9 |
10 | class TopicPagingSource constructor(
11 | private val v2exService: V2exService,
12 | private val topicId: String,
13 | private val reversed: Boolean
14 | ) : PagingSource() {
15 | companion object {
16 | private const val firstPageIndex = 1
17 | private const val startPageReversed = 999
18 | }
19 |
20 | private var startPage = if (reversed) startPageReversed else 1
21 |
22 | override fun getRefreshKey(state: PagingState): Int? {
23 | return state.anchorPosition?.let { anchorPosition ->
24 | val anchorPage = state.closestPageToPosition(anchorPosition)
25 | anchorPage?.prevKey?.plus(1) ?: anchorPage?.nextKey?.minus(1)
26 | }
27 | }
28 |
29 | override suspend fun load(params: LoadParams): LoadResult {
30 | return try {
31 | var page = params.key ?: startPage
32 | Log.d(TAG, "load, page = $page")
33 | val topicInfo = v2exService.topicDetails(topicId, page)
34 | if (page == startPageReversed) {
35 | startPage = topicInfo.totalPage
36 | page = startPage
37 | }
38 |
39 | val largerPage = if (page < topicInfo.totalPage) page + 1 else null
40 | val smallerPage = if (page <= firstPageIndex) null else page - 1
41 | val prevPage = if (reversed) largerPage else smallerPage
42 | val nextPage = if (reversed) smallerPage else largerPage
43 |
44 | val data: List = mutableListOf().apply {
45 | add(topicInfo)
46 | addAll(if (reversed) topicInfo.replies.reversed() else topicInfo.replies)
47 | }
48 | LoadResult.Page(data, prevPage, nextPage)
49 | } catch (e: Exception) {
50 | e.printStackTrace()
51 | LoadResult.Error(e)
52 | }
53 | }
54 |
55 |
56 | }
--------------------------------------------------------------------------------
/app/src/main/java/io/github/v2compose/network/bean/MyNodesInfo.java:
--------------------------------------------------------------------------------
1 | package io.github.v2compose.network.bean;
2 |
3 | import java.io.Serializable;
4 | import java.util.List;
5 |
6 | import io.github.v2compose.util.Check;
7 | import io.github.v2compose.util.Utils;
8 | import me.ghui.fruit.Attrs;
9 | import me.ghui.fruit.annotations.Pick;
10 |
11 | /**
12 | * Created by ghui on 18/05/2017.
13 | * https://www.v2ex.com/my/nodes
14 | */
15 |
16 | @Pick("div#my-nodes")
17 | public class MyNodesInfo extends BaseInfo {
18 |
19 | @Pick("a.fav-node")
20 | private List
- items;
21 |
22 | public List
- getItems() {
23 | return items;
24 | }
25 |
26 | @Override
27 | public boolean isValid() {
28 | if (Utils.listSize(items) <= 0) return true;
29 | return Check.notEmpty(items.get(0).title);
30 | }
31 |
32 | public static class Item implements Serializable {
33 | @Pick(value = "img", attr = Attrs.SRC)
34 | private String avatar;
35 | @Pick(value = "span.fav-node-name", attr = Attrs.OWN_TEXT)
36 | private String title;
37 | @Pick(value = "span.fade.f12")
38 | private int topicNum;
39 | @Pick(attr = Attrs.HREF)
40 | private String link;
41 |
42 | @Override
43 | public String toString() {
44 | return "Item{" +
45 | "avatar='" + avatar + '\'' +
46 | ", title='" + title + '\'' +
47 | ", topicNum=" + topicNum +
48 | ", link='" + link + '\'' +
49 | '}';
50 | }
51 |
52 | public String getAvatar() {
53 | return avatar == null ? "" : avatar;
54 | }
55 |
56 | public String getTitle() {
57 | return title;
58 | }
59 |
60 | public int getTopicNum() {
61 | return topicNum;
62 | }
63 |
64 | private String _name;
65 |
66 | public String getName() {
67 | if (_name != null) return _name;
68 | _name = link.substring("/go/".length());
69 | return _name;
70 | }
71 |
72 | public String getLink() {
73 | return link;
74 | }
75 | }
76 | }
77 |
--------------------------------------------------------------------------------
/app/src/main/java/io/github/v2compose/AppModule.kt:
--------------------------------------------------------------------------------
1 | package io.github.v2compose
2 |
3 | import android.content.Context
4 | import android.os.Build
5 | import coil.ImageLoader
6 | import coil.decode.GifDecoder
7 | import coil.decode.ImageDecoderDecoder
8 | import coil.decode.SvgDecoder
9 | import coil.disk.DiskCache
10 | import com.squareup.moshi.Moshi
11 | import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
12 | import dagger.Module
13 | import dagger.Provides
14 | import dagger.hilt.InstallIn
15 | import dagger.hilt.android.qualifiers.ApplicationContext
16 | import dagger.hilt.components.SingletonComponent
17 | import io.github.v2compose.network.di.ImageOkHttpClient
18 | import okhttp3.OkHttpClient
19 | import java.io.File
20 | import java.util.concurrent.ExecutorService
21 | import java.util.concurrent.Executors
22 | import javax.inject.Singleton
23 |
24 | @Module
25 | @InstallIn(SingletonComponent::class)
26 | object AppModule {
27 |
28 | @Provides
29 | @Singleton
30 | fun provideMoshi(): Moshi {
31 | return Moshi.Builder().addLast(KotlinJsonAdapterFactory()).build()
32 | }
33 |
34 | @Provides
35 | @Singleton
36 | fun provideDiskCache(@ApplicationContext context: Context): DiskCache {
37 | val dir = File(context.cacheDir, "image_cache")
38 | return DiskCache.Builder().directory(dir).maxSizePercent(0.02).build()
39 | }
40 |
41 | @Provides
42 | @Singleton
43 | fun provideImageLoader(
44 | @ApplicationContext context: Context,
45 | @ImageOkHttpClient httpClient: OkHttpClient,
46 | diskCache: DiskCache,
47 | ): ImageLoader {
48 | return ImageLoader.Builder(context)
49 | .okHttpClient(httpClient)
50 | .diskCache(diskCache)
51 | .components {
52 | if (Build.VERSION.SDK_INT >= 28) {
53 | add(ImageDecoderDecoder.Factory())
54 | } else {
55 | add(GifDecoder.Factory())
56 | }
57 | add(SvgDecoder.Factory())
58 | }.build()
59 | }
60 |
61 | @Provides
62 | @Singleton
63 | fun provideExecutorService(): ExecutorService {
64 | return Executors.newFixedThreadPool(4)
65 | }
66 |
67 | }
--------------------------------------------------------------------------------
/app/src/main/java/io/github/v2compose/repository/def/DefaultNodeRepository.kt:
--------------------------------------------------------------------------------
1 | package io.github.v2compose.repository.def
2 |
3 | import androidx.paging.Pager
4 | import androidx.paging.PagingConfig
5 | import androidx.paging.PagingData
6 | import io.github.v2compose.V2exUri
7 | import io.github.v2compose.core.extension.isRedirect
8 | import io.github.v2compose.datasource.AppStateStore
9 | import io.github.v2compose.datasource.NodePagingSource
10 | import io.github.v2compose.network.V2exService
11 | import io.github.v2compose.network.bean.NodeInfo
12 | import io.github.v2compose.network.bean.NodeTopicInfo
13 | import io.github.v2compose.network.bean.NodesNavInfo
14 | import io.github.v2compose.repository.NodeRepository
15 | import kotlinx.coroutines.flow.Flow
16 | import javax.inject.Inject
17 |
18 | class DefaultNodeRepository @Inject constructor(
19 | private val v2exService: V2exService,
20 | private val appStateStore: AppStateStore,
21 | ) : NodeRepository {
22 |
23 | override suspend fun getNodes() = v2exService.nodes()
24 |
25 | override suspend fun getAllNodes() = v2exService.allNodes()
26 |
27 | override val nodesNavInfo: Flow
28 | get() = appStateStore.nodesNavInfo
29 |
30 | override suspend fun getNodesNavInfo(): NodesNavInfo {
31 | return v2exService.nodesNavInfo().also {
32 | appStateStore.updateNodesNavInfo(it)
33 | }
34 | }
35 |
36 | override suspend fun getNodeInfo(nodeName: String): NodeInfo {
37 | return v2exService.nodeInfo(nodeName)
38 | }
39 |
40 | override fun getNodeTopicInfo(nodeName: String): Flow> {
41 | return Pager(PagingConfig(pageSize = 10)) { NodePagingSource(nodeName, v2exService) }.flow
42 | }
43 |
44 | override suspend fun doNodeAction(nodeName: String, actionUrl: String): NodeTopicInfo {
45 | val nodeUrl = V2exUri.nodeUrl(nodeName)
46 | return try {
47 | v2exService.nodeAction(nodeUrl, actionUrl)
48 | } catch (e: Exception) {
49 | e.printStackTrace()
50 | if (e.isRedirect(nodeUrl)) {
51 | v2exService.nodesInfo(nodeName, 1)
52 | } else {
53 | throw e
54 | }
55 | }
56 | }
57 | }
58 |
--------------------------------------------------------------------------------
/htmlText/build.gradle:
--------------------------------------------------------------------------------
1 | plugins {
2 | id 'com.android.library'
3 | id 'org.jetbrains.kotlin.android'
4 | id 'kotlin-kapt'
5 | }
6 |
7 | android {
8 | namespace 'io.github.cooaer.htmltext'
9 | compileSdk 33
10 |
11 | defaultConfig {
12 | minSdk 26
13 | targetSdk 33
14 |
15 | testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
16 | consumerProguardFiles "consumer-rules.pro"
17 | }
18 |
19 | buildTypes {
20 | release {
21 | minifyEnabled false
22 | proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
23 | }
24 | }
25 | compileOptions {
26 | sourceCompatibility JavaVersion.VERSION_1_8
27 | targetCompatibility JavaVersion.VERSION_1_8
28 | }
29 | kotlinOptions {
30 | jvmTarget = '1.8'
31 | }
32 | buildFeatures {
33 | compose true
34 | }
35 | composeOptions {
36 | kotlinCompilerExtensionVersion '1.4.0'
37 | }
38 | }
39 |
40 | dependencies {
41 |
42 | implementation 'androidx.core:core-ktx:1.10.0'
43 | implementation 'androidx.appcompat:appcompat:1.6.1'
44 | implementation 'com.google.android.material:material:1.8.0'
45 | testImplementation 'junit:junit:4.13.2'
46 | androidTestImplementation 'androidx.test.ext:junit:1.1.5'
47 | androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1'
48 |
49 | //compose
50 | implementation platform('androidx.compose:compose-bom:2022.12.00')
51 | implementation("androidx.compose.material:material")
52 | implementation 'androidx.compose.material3:material3'
53 | implementation "androidx.compose.ui:ui"
54 | implementation "androidx.compose.ui:ui-tooling-preview"
55 |
56 | androidTestImplementation platform('androidx.compose:compose-bom:2022.12.00')
57 | androidTestImplementation "androidx.compose.ui:ui-test-junit4"
58 | debugImplementation "androidx.compose.ui:ui-tooling"
59 | debugImplementation "androidx.compose.ui:ui-test-manifest"
60 |
61 | implementation 'org.jsoup:jsoup:1.15.1'
62 |
63 | implementation("io.coil-kt:coil-compose:2.2.2")
64 | implementation 'com.pierfrancescosoffritti.androidyoutubeplayer:core:11.1.0'
65 | }
--------------------------------------------------------------------------------
/app/src/main/java/io/github/v2compose/ui/search/SearchViewModel.kt:
--------------------------------------------------------------------------------
1 | package io.github.v2compose.ui.search
2 |
3 | import androidx.lifecycle.SavedStateHandle
4 | import androidx.lifecycle.ViewModel
5 | import androidx.lifecycle.viewModelScope
6 | import androidx.paging.cachedIn
7 | import dagger.hilt.android.lifecycle.HiltViewModel
8 | import io.github.v2compose.core.StringDecoder
9 | import io.github.v2compose.datasource.AppPreferences
10 | import io.github.v2compose.repository.TopicRepository
11 | import kotlinx.coroutines.ExperimentalCoroutinesApi
12 | import kotlinx.coroutines.flow.*
13 | import kotlinx.coroutines.launch
14 | import javax.inject.Inject
15 |
16 | @HiltViewModel
17 | class SearchViewModel @Inject constructor(
18 | savedStateHandle: SavedStateHandle,
19 | stringDecoder: StringDecoder,
20 | private val topicRepository: TopicRepository,
21 | private val appPreferences: AppPreferences,
22 | ) : ViewModel() {
23 |
24 | private val searchArgs = SearchArgs(savedStateHandle, stringDecoder)
25 |
26 | private val _keyword = MutableStateFlow(searchArgs.keyword)
27 | val keyword: StateFlow = _keyword.asStateFlow()
28 |
29 | @OptIn(ExperimentalCoroutinesApi::class)
30 | val historyKeywords = appPreferences.appSettings.mapLatest { it.searchKeywords }
31 | .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), listOf())
32 |
33 | @OptIn(ExperimentalCoroutinesApi::class)
34 | val topics = keyword.filterNot { it.isNullOrEmpty() }
35 | .flatMapLatest {
36 | topicRepository.search(it!!)
37 | }
38 | .cachedIn(viewModelScope)
39 |
40 | fun search(value: String) {
41 | viewModelScope.launch {
42 | _keyword.emit(value)
43 | val searchKeywords = historyKeywords.value.toMutableList()
44 | .also {
45 | it.remove(value)
46 | it.add(0, value)
47 | if(it.size > 10){
48 | it.removeLast()
49 | }
50 | }
51 | appPreferences.searchKeywords(searchKeywords)
52 | }
53 | }
54 |
55 | fun clearHistoryKeywords(){
56 | viewModelScope.launch{
57 | appPreferences.searchKeywords(listOf())
58 | }
59 | }
60 | }
--------------------------------------------------------------------------------
/app/src/main/java/io/github/v2compose/network/bean/LoginParam.java:
--------------------------------------------------------------------------------
1 | package io.github.v2compose.network.bean;
2 |
3 | import androidx.annotation.NonNull;
4 |
5 | import java.util.HashMap;
6 | import java.util.Map;
7 |
8 | import io.github.v2compose.util.Check;
9 | import me.ghui.fruit.Attrs;
10 | import me.ghui.fruit.annotations.Pick;
11 |
12 |
13 | /**
14 | * Created by ghui on 01/05/2017.
15 | */
16 |
17 | public class LoginParam extends BaseInfo {
18 | @Pick(value = "input.sl[type=text]", attr = "name")
19 | private String nameParam;
20 | @Pick(value = "input[type=password]", attr = "name")
21 | private String pswParam;
22 | @Pick(value = "input[name=once]", attr = "value")
23 | private String once;
24 | @Pick(value = "input[placeholder*=验证码]", attr = "name")
25 | private String captchaParam;
26 | @Pick(value = "div.problem", attr = Attrs.INNER_HTML)
27 | private String problem;
28 |
29 | @NonNull
30 | @Override
31 | public String toString() {
32 | return "LoginParam{" +
33 | "nameParam='" + nameParam + '\'' +
34 | ", pswParam='" + pswParam + '\'' +
35 | ", once='" + once + '\'' +
36 | ", captureParam='" + captchaParam + '\'' +
37 | ", problem='" + problem + '\'' +
38 | '}';
39 | }
40 |
41 | public String getNameParam() {
42 | return nameParam;
43 | }
44 |
45 | public String getPswParam() {
46 | return pswParam;
47 | }
48 |
49 | public String getOnce() {
50 | return once;
51 | }
52 |
53 | public boolean needCaptcha() {
54 | return Check.notEmpty(captchaParam);
55 | }
56 |
57 | public String getProblem() {
58 | return problem != null ? problem : "";
59 | }
60 |
61 | public Map toMap(String userName, String psw, String captcha) {
62 | Map map = new HashMap<>();
63 | map.put(nameParam, userName);
64 | map.put(pswParam, psw);
65 | map.put(captchaParam, captcha);
66 | map.put("once", once);
67 | // map.put("next", "/mission/daily");
68 | return map;
69 | }
70 |
71 | @Override
72 | public boolean isValid() {
73 | return Check.notEmpty(nameParam, pswParam, once);
74 | }
75 | }
76 |
--------------------------------------------------------------------------------
/app/src/main/java/io/github/v2compose/network/bean/HomePageInfo.java:
--------------------------------------------------------------------------------
1 | package io.github.v2compose.network.bean;
2 |
3 | import io.github.v2compose.util.AvatarUtils;
4 | import io.github.v2compose.util.Check;
5 | import me.ghui.fruit.Attrs;
6 | import me.ghui.fruit.annotations.Pick;
7 |
8 | @Pick("div#Wrapper")
9 | public class HomePageInfo extends BaseInfo {
10 | @Pick("h1")
11 | private String userName;
12 | @Pick(value = "img.avatar", attr = Attrs.SRC)
13 | private String avatar;
14 | @Pick("td[valign=top] > span.gray")
15 | private String desc;
16 | @Pick("strong.online")
17 | private String online;
18 |
19 | @Pick("a[href=/my/nodes] span.bigger")
20 | private int nodes;
21 |
22 | @Pick("a[href=/my/topics] span.bigger")
23 | private int topics;
24 |
25 | @Pick("a[href=/my/following] span.bigger")
26 | private int following;
27 |
28 | private String getUrl(String onclick) {
29 | if (Check.notEmpty(onclick)) {
30 | String reg = "{ location.href = '";
31 | int start = onclick.indexOf(reg) + reg.length();
32 | int end = onclick.lastIndexOf("'");
33 | return onclick.substring(start, end);
34 | }
35 | return null;
36 | }
37 |
38 | public String getUserName() {
39 | return userName;
40 | }
41 |
42 | public String getAvatar() {
43 | return AvatarUtils.adjustAvatar(avatar);
44 | }
45 |
46 | public String getDesc() {
47 | return desc;
48 | }
49 |
50 | public boolean isOnline() {
51 | return Check.notEmpty(online) && online.equals("ONLINE");
52 | }
53 |
54 | public int getNodes() {
55 | return nodes;
56 | }
57 |
58 | public int getTopics() {
59 | return topics;
60 | }
61 |
62 | public int getFollowing() {
63 | return following;
64 | }
65 |
66 | @Override
67 | public String toString() {
68 | return "UserPageInfo{" +
69 | "userName='" + userName + '\'' +
70 | ", avatar='" + avatar + '\'' +
71 | ", desc='" + desc + '\'' +
72 | ", online='" + online + '\'' +
73 | '}';
74 | }
75 |
76 | @Override
77 | public boolean isValid() {
78 | return Check.notEmpty(userName);
79 | }
80 | }
81 |
--------------------------------------------------------------------------------
/app/src/main/java/io/github/v2compose/ui/user/UserNavigation.kt:
--------------------------------------------------------------------------------
1 | package io.github.v2compose.ui.user
2 |
3 | import android.net.Uri
4 | import androidx.compose.animation.ExperimentalAnimationApi
5 | import androidx.lifecycle.SavedStateHandle
6 | import androidx.navigation.NavController
7 | import androidx.navigation.NavGraphBuilder
8 | import androidx.navigation.NavType
9 | import androidx.navigation.navArgument
10 | import com.google.accompanist.navigation.animation.composable
11 | import io.github.v2compose.core.StringDecoder
12 | import io.github.v2compose.ui.common.OnHtmlImageClick
13 |
14 | private const val argsUserName = "userName"
15 | private const val argsAvatar = "userAvatar"
16 |
17 | const val userScreenNavigationRoute = "/member/{$argsUserName}?userAvatar={$argsAvatar}"
18 |
19 | data class UserArgs(val userName: String, val avatar: String? = null) {
20 | constructor(savedStateHandle: SavedStateHandle, stringDecoder: StringDecoder) : this(
21 | stringDecoder.decodeString(checkNotNull(savedStateHandle[argsUserName])),
22 | savedStateHandle.get(argsAvatar)?.let { stringDecoder.decodeString(it) },
23 | )
24 | }
25 |
26 | fun NavController.navigateToUser(userName: String, userAvatar: String? = null) {
27 | val encodedUserName = Uri.encode(userName)
28 | val encodedUserAvatar = Uri.encode(userAvatar) ?: ""
29 | navigate("/member/$encodedUserName?userAvatar=$encodedUserAvatar")
30 | }
31 |
32 | @OptIn(ExperimentalAnimationApi::class)
33 | fun NavGraphBuilder.userScreen(
34 | onBackClick: () -> Unit,
35 | onTopicClick: (String) -> Unit,
36 | onNodeClick: (String, String) -> Unit,
37 | openUri: (String) -> Unit,
38 | onHtmlImageClick: OnHtmlImageClick,
39 | ) {
40 | composable(
41 | route = userScreenNavigationRoute,
42 | arguments = listOf(
43 | navArgument(argsUserName) { type = NavType.StringType },
44 | navArgument(argsAvatar) {
45 | type = NavType.StringType
46 | nullable = true
47 | },
48 | )
49 | ) {
50 | UserScreenRoute(
51 | onBackClick = onBackClick,
52 | onTopicClick = onTopicClick,
53 | onNodeClick = onNodeClick,
54 | openUri = openUri,
55 | onHtmlImageClick = onHtmlImageClick,
56 | )
57 | }
58 | }
--------------------------------------------------------------------------------
/app/src/main/java/io/github/v2compose/ui/settings/SettingsScreenState.kt:
--------------------------------------------------------------------------------
1 | package io.github.v2compose.ui.settings
2 |
3 | import android.content.Context
4 | import androidx.compose.material3.SnackbarDuration
5 | import androidx.compose.material3.SnackbarHostState
6 | import androidx.compose.runtime.Composable
7 | import androidx.compose.runtime.Stable
8 | import androidx.compose.runtime.remember
9 | import androidx.compose.ui.platform.LocalContext
10 | import io.github.v2compose.LocalSnackbarHostState
11 | import io.github.v2compose.R
12 | import io.github.v2compose.network.bean.Release
13 | import kotlinx.coroutines.async
14 | import kotlinx.coroutines.coroutineScope
15 | import javax.inject.Inject
16 |
17 | @Composable
18 | fun rememberSettingsScreenState(
19 | context: Context = LocalContext.current,
20 | snackbarHostState: SnackbarHostState = LocalSnackbarHostState.current,
21 | ): SettingsScreenState {
22 | return remember(context, snackbarHostState) {
23 | SettingsScreenState(context, snackbarHostState)
24 | }
25 | }
26 |
27 | @Stable
28 | class SettingsScreenState @Inject constructor(
29 | private val context: Context,
30 | val snackbarHostState: SnackbarHostState,
31 | ) {
32 |
33 | suspend fun checkForUpdates(
34 | checkForUpdates: suspend () -> Release,
35 | onNewRelease: (Release) -> Unit,
36 | ) = coroutineScope {
37 | val showSnackbar =
38 | async {
39 | snackbarHostState.showSnackbar(
40 | context.getString(R.string.checking_for_updates),
41 | duration = SnackbarDuration.Short,
42 | )
43 | }
44 | val check = async { checkForUpdates() }
45 | val release = check.await()
46 | showSnackbar.cancel()
47 | if (release.isValid()) {
48 | onNewRelease(release)
49 | } else {
50 | snackbarHostState.showSnackbar(
51 | context.getString(R.string.no_updates),
52 | duration = SnackbarDuration.Short,
53 | )
54 | }
55 | }
56 |
57 | suspend fun logout(logout: suspend () -> Unit) {
58 | logout()
59 | snackbarHostState.showSnackbar(
60 | context.getString(R.string.logout_success),
61 | duration = SnackbarDuration.Short
62 | )
63 | }
64 |
65 | }
--------------------------------------------------------------------------------
/app/src/main/java/io/github/v2compose/network/bean/DailyInfo.java:
--------------------------------------------------------------------------------
1 | package io.github.v2compose.network.bean;
2 |
3 | import io.github.v2compose.util.Check;
4 | import io.github.v2compose.util.UriUtils;
5 | import io.github.v2compose.util.Utils;
6 | import me.ghui.fruit.annotations.Pick;
7 |
8 | /**
9 | * Created by ghui on 07/08/2017.
10 | */
11 |
12 | public class DailyInfo extends BaseInfo {
13 | @Pick(value = "[href^=/member]", attr = "href")
14 | private String userLink;
15 | @Pick(value = "img[src*=avatar/]", attr = "src")
16 | private String avatar;
17 | @Pick("h1")
18 | private String title;
19 | @Pick("div.cell:contains(已连续)")
20 | private String continuousLoginDaysText;
21 | @Pick(value = "div.cell input[type=button]", attr = "onclick")
22 | private String checkInUrl; //location.href = '/mission/daily/redeem?once=84830';
23 |
24 | public boolean hadCheckedIn() {
25 | return Check.notEmpty(checkInUrl) && checkInUrl.equals("location.href = '/balance';");
26 | }
27 |
28 | public String getContinuousLoginDaysText() {
29 | return continuousLoginDaysText;
30 | }
31 |
32 | public String getContinuousLoginDays() {
33 | return Utils.extractDigits(continuousLoginDaysText);
34 | }
35 |
36 |
37 | public String getUserName() {
38 | if (Check.isEmpty(userLink)) {
39 | return null;
40 | }
41 | return userLink.split("/")[2];
42 | }
43 |
44 | public String getAvatar() {
45 | if (Check.isEmpty(avatar)) return null;
46 | return avatar.replace("normal.png", "large.png");
47 | }
48 |
49 | public String once() {
50 | String result = UriUtils.getParamValue(checkInUrl, "once");
51 | if (Check.notEmpty(result)) {
52 | result = result.replace("';", "");
53 | }
54 | return result;
55 | }
56 |
57 | @Override
58 | public boolean isValid() {
59 | return Check.notEmpty(checkInUrl);
60 | }
61 |
62 | @Override
63 | public String toString() {
64 | return "DailyInfo{" +
65 | "title='" + title + '\'' +
66 | ", continuousLoginDay='" + getContinuousLoginDays() + '\'' +
67 | ", checkinUrl='" + checkInUrl + '\'' +
68 | ", once='" + once() + '\'' +
69 | '}';
70 | }
71 | }
72 |
--------------------------------------------------------------------------------
/app/src/main/java/io/github/v2compose/ui/common/ListDialog.kt:
--------------------------------------------------------------------------------
1 | package io.github.v2compose.ui.common
2 |
3 | import androidx.compose.foundation.clickable
4 | import androidx.compose.foundation.layout.*
5 | import androidx.compose.material3.AlertDialog
6 | import androidx.compose.material3.RadioButton
7 | import androidx.compose.material3.Text
8 | import androidx.compose.material3.TextButton
9 | import androidx.compose.runtime.Composable
10 | import androidx.compose.ui.Alignment
11 | import androidx.compose.ui.Modifier
12 | import androidx.compose.ui.res.stringResource
13 | import androidx.compose.ui.tooling.preview.Preview
14 | import androidx.compose.ui.unit.dp
15 | import io.github.v2compose.R
16 |
17 |
18 | @Composable
19 | fun SingleChoiceListDialog(
20 | title: String? = null,
21 | entries: List,
22 | selectedIndex: Int,
23 | onEntryClick: (Int) -> Unit,
24 | onCancel: (() -> Unit)
25 | ) {
26 | AlertDialog(onDismissRequest = onCancel,
27 | confirmButton = {
28 | TextButton(onClick = { onCancel() }) {
29 | Text(stringResource(id = R.string.ok))
30 | }
31 | },
32 | title = {
33 | title?.let {
34 | Text(title)
35 | }
36 | },
37 | text = {
38 | Column {
39 | entries.forEachIndexed { index, entry ->
40 | Row(
41 | modifier = Modifier
42 | .fillMaxWidth()
43 | .clickable { onEntryClick(index) },
44 | verticalAlignment = Alignment.CenterVertically
45 | ) {
46 | RadioButton(
47 | selected = index == selectedIndex,
48 | onClick = { onEntryClick(index) })
49 | Spacer(modifier = Modifier.width(8.dp))
50 | Text(entry)
51 | }
52 | }
53 | }
54 | }
55 | )
56 | }
57 |
58 |
59 | @Preview(showBackground = true, widthDp = 440)
60 | @Composable
61 | fun SingleChoiceListDialogPreview() {
62 | SingleChoiceListDialog(
63 | title = "浏览器",
64 | entries = listOf("内置浏览器", "外置浏览器"),
65 | selectedIndex = 0,
66 | onEntryClick = {},
67 | onCancel = {})
68 | }
--------------------------------------------------------------------------------
/app/src/main/java/io/github/v2compose/network/bean/BingSearchResultInfo.java:
--------------------------------------------------------------------------------
1 | package io.github.v2compose.network.bean;
2 |
3 | import java.util.List;
4 |
5 | import io.github.v2compose.util.Check;
6 | import io.github.v2compose.util.Utils;
7 | import me.ghui.fruit.Attrs;
8 | import me.ghui.fruit.annotations.Pick;
9 |
10 | /**
11 | * Created by ghui on 02/06/2017.
12 | * http://cn.bing.com/search?q=android+site%3av2ex.com&first=21
13 | */
14 |
15 | @Pick("ol[id=b_results]")
16 | public class BingSearchResultInfo extends BaseInfo {
17 |
18 | @Pick("li.b_algo")
19 | private List
- items;
20 | @Pick(value = "a.sb_fullnpl")
21 | private String next;
22 | @Pick(value = "a.sb_halfnext")
23 | private String next2;
24 |
25 | public boolean hasMore() {
26 | return Check.notEmpty(next) || Check.notEmpty(next2);
27 | }
28 |
29 | public List
- getItems() {
30 | return items;
31 | }
32 |
33 | @Override
34 | public String toString() {
35 | return "BingSearchResultInfo{" +
36 | "hasNext=" + hasMore() +
37 | ",items=" + items +
38 | '}';
39 | }
40 |
41 | @Override
42 | public boolean isValid() {
43 | if (Utils.listSize(items) <= 0) return true;
44 | return Check.notEmpty(items.get(0).link);
45 | }
46 |
47 | public static class Item {
48 | @Pick(value = "h2")
49 | private String title;
50 | @Pick(value = "div.b_caption p")
51 | private String content;
52 | @Pick(value = "div.b_algoheader a", attr = Attrs.HREF)
53 | private String link;
54 |
55 | public String getTitle() {
56 | return title;
57 | }
58 |
59 | public String getContent() {
60 | if (Check.isEmpty(content)) {
61 | return "";
62 | }
63 | content = content.replace("移动版", "");
64 | return content;
65 | }
66 |
67 | public String getLink() {
68 | return link;
69 | }
70 |
71 | @Override
72 | public String toString() {
73 | return "Item{" +
74 | "title='" + title + '\'' +
75 | ", content='" + content + '\'' +
76 | ", link='" + link + '\'' +
77 | '}';
78 | }
79 | }
80 | }
81 |
--------------------------------------------------------------------------------
/app/src/main/java/io/github/v2compose/ui/node/NodeNavigation.kt:
--------------------------------------------------------------------------------
1 | package io.github.v2compose.ui.node
2 |
3 | import android.net.Uri
4 | import androidx.compose.animation.ExperimentalAnimationApi
5 | import androidx.lifecycle.SavedStateHandle
6 | import androidx.navigation.NavController
7 | import androidx.navigation.NavGraphBuilder
8 | import androidx.navigation.NavType
9 | import androidx.navigation.navArgument
10 | import com.google.accompanist.navigation.animation.composable
11 | import io.github.v2compose.core.StringDecoder
12 | import io.github.v2compose.network.bean.NodeTopicInfo
13 |
14 | private const val ArgsNodeName = "nodeName"
15 | private const val ArgsNodeTitle = "nodeTitle"
16 |
17 | const val nodeNavigationNavigationRoute = "/go/{$ArgsNodeName}?$ArgsNodeTitle={$ArgsNodeTitle}"
18 |
19 | data class NodeArgs(val nodeName: String, val nodeTitle: String? = null) {
20 | constructor(
21 | savedStateHandle: SavedStateHandle,
22 | stringDecoder: StringDecoder
23 | ) : this(
24 | nodeName = stringDecoder.decodeString(checkNotNull(savedStateHandle[ArgsNodeName])),
25 | nodeTitle = savedStateHandle.get(ArgsNodeTitle)
26 | .let { if (it == null) null else stringDecoder.decodeString(it) }
27 | )
28 | }
29 |
30 | fun NavController.navigateToNode(nodeName: String, nodeTitle: String? = null) {
31 | val encodedNodeName = Uri.encode(nodeName)
32 | val encodedNodeTitle = Uri.encode(nodeTitle)
33 | navigate("/go/$encodedNodeName?$ArgsNodeTitle=${encodedNodeTitle ?: ""}")
34 | }
35 |
36 | @OptIn(ExperimentalAnimationApi::class)
37 | fun NavGraphBuilder.nodeScreen(
38 | onBackClick: () -> Unit,
39 | onTopicClick: (NodeTopicInfo.Item) -> Unit,
40 | onUserAvatarClick: (String, String) -> Unit,
41 | openUri: (String) -> Unit,
42 | ) {
43 | composable(
44 | route = nodeNavigationNavigationRoute,
45 | arguments = listOf(
46 | navArgument(ArgsNodeName) { type = NavType.StringType },
47 | navArgument(ArgsNodeTitle) {
48 | type = NavType.StringType
49 | nullable = true
50 | })
51 | ) {
52 | NodeRoute(
53 | onBackClick = onBackClick,
54 | onTopicClick = onTopicClick,
55 | onUserAvatarClick = onUserAvatarClick,
56 | openUri = openUri,
57 | )
58 | }
59 | }
--------------------------------------------------------------------------------
/app/src/main/java/io/github/v2compose/repository/TopicRepository.kt:
--------------------------------------------------------------------------------
1 | package io.github.v2compose.repository
2 |
3 | import androidx.paging.PagingData
4 | import io.github.v2compose.bean.ContentFormat
5 | import io.github.v2compose.bean.DraftTopic
6 | import io.github.v2compose.network.bean.*
7 | import kotlinx.coroutines.flow.Flow
8 |
9 | interface TopicRepository {
10 | suspend fun getTopicInfo(topicId: String): TopicInfo
11 | fun getTopic(topicId: String, initialPage: Int?, reversed: Boolean): Flow>
12 |
13 | val repliesOrderReversed: Flow
14 |
15 | suspend fun toggleRepliesReversed()
16 |
17 | val highlightOpReply: Flow
18 |
19 | fun search(keyword: String): Flow>
20 |
21 | val topicTitleOverview: Flow
22 |
23 | val replyWithFloor: Flow
24 |
25 | suspend fun doTopicAction(
26 | action: String,
27 | method: ActionMethod,
28 | topicId: String,
29 | once: String
30 | ): V2exResult
31 |
32 | suspend fun doReplyAction(
33 | action: String,
34 | method: ActionMethod,
35 | topicId: String,
36 | replyId: String,
37 | once: String
38 | ): V2exResult
39 |
40 | suspend fun ignoreReply(topicId: String, replyId: String, once: String): Boolean
41 |
42 | suspend fun replyTopic(topicId: String, content: String, once: String): ReplyTopicResultInfo
43 |
44 | val draftTopic: Flow
45 |
46 | suspend fun saveDraftTopic(
47 | title: String,
48 | content: String,
49 | contentFormat: ContentFormat,
50 | node: TopicNode?
51 | )
52 |
53 | suspend fun getCreateTopicPageInfo(): CreateTopicPageInfo
54 |
55 | suspend fun getTopicNodes(): List
56 |
57 | suspend fun createTopic(
58 | title: String,
59 | content: String,
60 | contentFormat: ContentFormat,
61 | nodeName: String,
62 | once: String
63 | ): CreateTopicPageInfo
64 |
65 | suspend fun getAppendTopicPageInfo(topicId: String): AppendTopicPageInfo
66 |
67 | suspend fun addSupplement(
68 | topicId: String,
69 | supplement: String,
70 | contentFormat: ContentFormat,
71 | once: String
72 | ): AppendTopicPageInfo
73 | }
74 |
75 | enum class ActionMethod {
76 | Get, Post
77 | }
--------------------------------------------------------------------------------
/app/src/main/java/io/github/v2compose/util/UriUtils.kt:
--------------------------------------------------------------------------------
1 | package io.github.v2compose.util
2 |
3 | import android.net.Uri
4 | import java.net.URL
5 |
6 | /**
7 | * Created by ghui on 02/06/2017.
8 | */
9 | object UriUtils {
10 |
11 | @JvmStatic
12 | fun getLastSegment(url: String): String {
13 | var newUrl = url
14 | if (Check.isEmpty(newUrl)) return ""
15 | if (newUrl.contains("#")) {
16 | newUrl = newUrl.substring(0, newUrl.indexOf("#"))
17 | }
18 | return newUrl.replaceFirst(".*/([^/?]+).*".toRegex(), "$1")
19 | }
20 |
21 | @JvmStatic
22 | fun getParamValue(url: String?, paramName: String?): String? {
23 | return if (Check.isEmpty(url)) null else Uri.parse(url).getQueryParameter(paramName)
24 | }
25 |
26 | fun checkSchema(url: String): String? {
27 | if (Check.isEmpty(url)) return null
28 | var newUrl = url
29 | if (!url.startsWith("http") || !url.startsWith("https")) {
30 | val schema = if (url.contains("i.v2ex.co")) "https" else "http"
31 | newUrl = if (url.startsWith("//")) {
32 | "$schema:$url"
33 | } else {
34 | "$schema://$url"
35 | }
36 | }
37 | return if (!isValideUrl(newUrl)) "" else newUrl
38 | }
39 |
40 | fun isValideUrl(url: String?): Boolean {
41 | try {
42 | URL(url).toURI()
43 | } catch (e: Exception) {
44 | return false
45 | }
46 | return true
47 | }
48 |
49 | /**
50 | * 获取 mimeType
51 | */
52 | fun getMimeType(url: String): String {
53 | return if (url.endsWith(".png") || url.endsWith(".PNG")) {
54 | "data:image/png;base64,"
55 | } else if (url.endsWith(".jpg") || url.endsWith(".jpeg") || url.endsWith(".JPG") || url.endsWith(
56 | ".JPEG"
57 | )
58 | ) {
59 | "data:image/jpg;base64,"
60 | } else if (url.endsWith(".gif") || url.endsWith(".GIF")) {
61 | "data:image/gif;base64,"
62 | } else {
63 | ""
64 | }
65 | }
66 |
67 | fun isImg(url: String): Boolean {
68 | val REGULAR_RULE =
69 | "(?:([^:/?#]+):)?(?://([^/?#]*))?([^?#]*\\.(?:jpg|jpeg|gif|png|JPG|JPEG|GIF|PNG))(?:\\?([^#]*))?(?:#(.*))?"
70 | return url.matches(Regex(REGULAR_RULE))
71 | }
72 | }
--------------------------------------------------------------------------------