├── 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 | 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 | } --------------------------------------------------------------------------------