├── .gitignore ├── LICENSE ├── README.md ├── app ├── .gitignore ├── build.gradle ├── proguard-rules.pro ├── schemas │ └── com.czbix.v2ex.db.V2Db │ │ └── 1.json └── src │ ├── androidTest │ └── kotlin │ │ └── com │ │ └── czbix │ │ └── v2ex │ │ └── ui │ │ └── util │ │ └── HtmlTest.kt │ ├── debug │ ├── AndroidManifest.xml │ ├── kotlin │ │ └── com │ │ │ └── czbix │ │ │ └── v2ex │ │ │ ├── DebugAppCtx.kt │ │ │ └── DebugHelpersImpl.kt │ └── res │ │ └── values │ │ ├── config.xml │ │ └── strings.xml │ ├── main │ ├── AndroidManifest.xml │ ├── ic_launcher-web.png │ ├── java │ │ └── com │ │ │ └── czbix │ │ │ └── v2ex │ │ │ ├── BackupAgent.java │ │ │ ├── common │ │ │ ├── DeviceStatus.java │ │ │ ├── PrefStore.java │ │ │ └── exception │ │ │ │ ├── ExIllegalStateException.java │ │ │ │ ├── FatalException.java │ │ │ │ ├── NotImplementedException.java │ │ │ │ ├── RemoteException.java │ │ │ │ ├── RequestException.java │ │ │ │ └── UnauthorizedException.java │ │ │ ├── dao │ │ │ ├── ConfigDao.java │ │ │ ├── DaoBase.java │ │ │ ├── NodeDao.java │ │ │ ├── SqlOperation.java │ │ │ └── V2exDb.java │ │ │ ├── eventbus │ │ │ ├── LoginEvent.java │ │ │ └── executor │ │ │ │ └── HandlerExecutor.java │ │ │ ├── helper │ │ │ ├── CustomTabsHelper.java │ │ │ └── MultiList.java │ │ │ ├── model │ │ │ ├── Favable.java │ │ │ ├── Ignorable.java │ │ │ ├── LoginResult.java │ │ │ ├── Notification.java │ │ │ ├── Page.java │ │ │ ├── Tab.java │ │ │ ├── Thankable.java │ │ │ └── db │ │ │ │ ├── Draft.java │ │ │ │ ├── TopicDraft.java │ │ │ │ └── ViewHistory.java │ │ │ ├── network │ │ │ ├── Etag.java │ │ │ └── HttpStatus.java │ │ │ ├── parser │ │ │ └── AsyncImageGetter.java │ │ │ ├── ui │ │ │ ├── LoadingActivity.java │ │ │ ├── TopicEditActivity.java │ │ │ ├── adapter │ │ │ │ ├── NotificationAdapter.java │ │ │ │ └── StableArrayAdapter.java │ │ │ ├── fragment │ │ │ │ ├── BaseTabFragment.java │ │ │ │ ├── CategoryTabFragment.java │ │ │ │ ├── FavNodeFragment.java │ │ │ │ ├── FavoriteTabFragment.java │ │ │ │ ├── NodeListFragment.java │ │ │ │ └── NotificationListFragment.java │ │ │ ├── helper │ │ │ │ ├── ForceTouchDetector.java │ │ │ │ └── ReplyFormHelper.java │ │ │ ├── loader │ │ │ │ ├── AsyncTaskLoader.java │ │ │ │ └── NotificationLoader.java │ │ │ ├── model │ │ │ │ └── EpoxyDataBindingConfig.java │ │ │ ├── preference │ │ │ │ └── TabListPreference.java │ │ │ ├── util │ │ │ │ └── Html.java │ │ │ └── widget │ │ │ │ ├── CommentView.java │ │ │ │ ├── DividerItemDecoration.java │ │ │ │ ├── DragSortListView.java │ │ │ │ ├── ExArrayAdapter.java │ │ │ │ ├── ExSwipeRefreshLayout.java │ │ │ │ ├── HtmlMovementMethod.java │ │ │ │ └── SearchListView.java │ │ │ └── util │ │ │ ├── CrashlyticsUtils.java │ │ │ ├── ExecutorUtils.java │ │ │ ├── LogUtils.java │ │ │ ├── TrackerUtils.kt │ │ │ └── UserUtils.java │ ├── kotlin │ │ └── com │ │ │ └── czbix │ │ │ └── v2ex │ │ │ ├── AppCtx.kt │ │ │ ├── DebugHelpers.kt │ │ │ ├── ViewerProvider.kt │ │ │ ├── common │ │ │ ├── NotificationStatus.kt │ │ │ ├── UpdateInfo.kt │ │ │ ├── UserState.kt │ │ │ └── exception │ │ │ │ ├── ConnectionException.kt │ │ │ │ ├── RestrictedException.kt │ │ │ │ ├── TwoFactorAuthException.kt │ │ │ │ └── UrlRedirectException.kt │ │ │ ├── db │ │ │ ├── Comment.kt │ │ │ ├── CommentAndMember.kt │ │ │ ├── CommentDao.kt │ │ │ ├── Member.kt │ │ │ ├── TopicRecord.kt │ │ │ ├── TopicRecordDao.kt │ │ │ └── V2Db.kt │ │ │ ├── event │ │ │ ├── AppUpdateEvent.kt │ │ │ ├── BaseEvent.kt │ │ │ └── DeviceRegisterEvent.java │ │ │ ├── helper │ │ │ ├── JsoupObjects.kt │ │ │ └── RxBus.kt │ │ │ ├── inject │ │ │ ├── ActivityModule.kt │ │ │ ├── AppComponent.kt │ │ │ ├── AppInjector.kt │ │ │ ├── AppModule.kt │ │ │ ├── DbModule.kt │ │ │ ├── Injectable.kt │ │ │ ├── MainFragmentModule.kt │ │ │ ├── NightModeModule.kt │ │ │ ├── Scoped.kt │ │ │ ├── TopicFragmentModule.kt │ │ │ ├── ViewModelKey.kt │ │ │ └── ViewModelModule.kt │ │ │ ├── model │ │ │ ├── AbsentLiveData.kt │ │ │ ├── Avatar.kt │ │ │ ├── ContentBlock.kt │ │ │ ├── EmptyLiveData.kt │ │ │ ├── NetworkBoundResource.kt │ │ │ ├── Node.kt │ │ │ ├── Postscript.kt │ │ │ ├── PreferenceStorage.kt │ │ │ ├── Resource.kt │ │ │ ├── ServerConfig.kt │ │ │ ├── Topic.kt │ │ │ ├── TopicResponse.kt │ │ │ ├── ViewModelFactory.kt │ │ │ └── loader │ │ │ │ └── GooglePhotoUrlLoader.kt │ │ │ ├── network │ │ │ ├── CzRequestHelper.kt │ │ │ ├── GlideConfig.kt │ │ │ ├── NetworkState.kt │ │ │ ├── RequestHelper.kt │ │ │ ├── V2exService.kt │ │ │ └── repository │ │ │ │ ├── BaseRepository.kt │ │ │ │ └── TopicRepository.kt │ │ │ ├── parser │ │ │ ├── MyselfParser.kt │ │ │ ├── NotificationParser.kt │ │ │ ├── Parser.kt │ │ │ ├── TopicListParser.kt │ │ │ └── TopicParser.kt │ │ │ ├── presenter │ │ │ └── TopicSearchPresenter.kt │ │ │ ├── res │ │ │ └── GoogleImg.kt │ │ │ ├── ui │ │ │ ├── AutoClearedValue.kt │ │ │ ├── BaseActivity.kt │ │ │ ├── BindingAdapters.kt │ │ │ ├── DebugActivity.kt │ │ │ ├── ExHolder.kt │ │ │ ├── LoginActivity.kt │ │ │ ├── MainActivity.kt │ │ │ ├── SettingsActivity.kt │ │ │ ├── TopicActivity.kt │ │ │ ├── adapter │ │ │ │ ├── CommentController.kt │ │ │ │ ├── NodeController.kt │ │ │ │ └── TopicController.kt │ │ │ ├── common │ │ │ │ └── RetryCallback.kt │ │ │ ├── fragment │ │ │ │ ├── TopicFragment.kt │ │ │ │ ├── TopicListFragment.kt │ │ │ │ └── TwoFactorAuthDialog.kt │ │ │ ├── loader │ │ │ │ └── TopicListLoader.kt │ │ │ ├── model │ │ │ │ ├── NightModeViewModel.kt │ │ │ │ └── TopicViewModel.kt │ │ │ ├── settings │ │ │ │ ├── PrefsFragment.kt │ │ │ │ ├── SettingsModule.kt │ │ │ │ └── SettingsViewModel.kt │ │ │ └── widget │ │ │ │ ├── AvatarView.kt │ │ │ │ ├── SearchBoxLayout.kt │ │ │ │ └── TopicView.kt │ │ │ └── util │ │ │ ├── CoroutineUtils.kt │ │ │ ├── CrashlyticsTree.kt │ │ │ ├── ExceptionUtils.kt │ │ │ ├── GsonUtils.kt │ │ │ ├── IoUtils.kt │ │ │ ├── LiveDataUtils.kt │ │ │ ├── MiscUtils.kt │ │ │ ├── RxUtils.kt │ │ │ └── ViewUtils.kt │ └── res │ │ ├── color │ │ └── btn_reply_submit.xml │ │ ├── drawable-anydpi │ │ ├── ic_arrow_back_black_24dp.xml │ │ ├── ic_close_black_24dp.xml │ │ ├── ic_dashboard_black_24dp.xml │ │ ├── ic_explore_black_24dp.xml │ │ ├── ic_favorite_black_24dp.xml │ │ ├── ic_feedback_black_24dp.xml │ │ ├── ic_info_outline_black_24dp.xml │ │ ├── ic_notifications_black_24dp.xml │ │ ├── ic_notifications_none_black_24dp.xml │ │ ├── ic_send_black_24dp.xml │ │ └── ic_settings_black_24dp.xml │ │ ├── drawable-hdpi-v23 │ │ └── window_background_statusbar_toolbar_tab.9.png │ │ ├── drawable-hdpi │ │ ├── ic_card_giftcard_white_24dp.png │ │ ├── ic_favorite_border_white_24dp.png │ │ ├── ic_favorite_white_24dp.png │ │ ├── ic_keyboard_arrow_down_white_24dp.png │ │ ├── ic_notifications_white_24dp.png │ │ ├── ic_refresh_white_24dp.png │ │ ├── ic_search_white_24dp.png │ │ ├── ic_sync_disabled_white_24dp.png │ │ ├── ic_sync_problem_white_24dp.png │ │ ├── ic_sync_white_24dp.png │ │ └── window_background_statusbar_toolbar_tab.9.png │ │ ├── drawable-mdpi-v23 │ │ └── window_background_statusbar_toolbar_tab.9.png │ │ ├── drawable-mdpi │ │ ├── avatar_default.png │ │ ├── ic_card_giftcard_white_24dp.png │ │ ├── ic_favorite_border_white_24dp.png │ │ ├── ic_favorite_white_24dp.png │ │ ├── ic_keyboard_arrow_down_white_24dp.png │ │ ├── ic_notifications_white_24dp.png │ │ ├── ic_refresh_white_24dp.png │ │ ├── ic_search_white_24dp.png │ │ ├── ic_sync_disabled_white_24dp.png │ │ ├── ic_sync_problem_white_24dp.png │ │ ├── ic_sync_white_24dp.png │ │ └── window_background_statusbar_toolbar_tab.9.png │ │ ├── drawable-sw600dp-hdpi-v23 │ │ └── window_background_statusbar_toolbar_tab.9.png │ │ ├── drawable-sw600dp-hdpi │ │ └── window_background_statusbar_toolbar_tab.9.png │ │ ├── drawable-sw600dp-mdpi-v23 │ │ └── window_background_statusbar_toolbar_tab.9.png │ │ ├── drawable-sw600dp-mdpi │ │ └── window_background_statusbar_toolbar_tab.9.png │ │ ├── drawable-sw600dp-xhdpi-v23 │ │ └── window_background_statusbar_toolbar_tab.9.png │ │ ├── drawable-sw600dp-xhdpi │ │ └── window_background_statusbar_toolbar_tab.9.png │ │ ├── drawable-sw600dp-xxhdpi-v23 │ │ └── window_background_statusbar_toolbar_tab.9.png │ │ ├── drawable-sw600dp-xxhdpi │ │ └── window_background_statusbar_toolbar_tab.9.png │ │ ├── drawable-sw600dp-xxxhdpi-v23 │ │ └── window_background_statusbar_toolbar_tab.9.png │ │ ├── drawable-sw600dp-xxxhdpi │ │ └── window_background_statusbar_toolbar_tab.9.png │ │ ├── drawable-xhdpi-v23 │ │ └── window_background_statusbar_toolbar_tab.9.png │ │ ├── drawable-xhdpi │ │ ├── ic_card_giftcard_white_24dp.png │ │ ├── ic_favorite_border_white_24dp.png │ │ ├── ic_favorite_white_24dp.png │ │ ├── ic_keyboard_arrow_down_white_24dp.png │ │ ├── ic_notifications_white_24dp.png │ │ ├── ic_refresh_white_24dp.png │ │ ├── ic_search_white_24dp.png │ │ ├── ic_sync_disabled_white_24dp.png │ │ ├── ic_sync_problem_white_24dp.png │ │ ├── ic_sync_white_24dp.png │ │ └── window_background_statusbar_toolbar_tab.9.png │ │ ├── drawable-xxhdpi-v23 │ │ └── window_background_statusbar_toolbar_tab.9.png │ │ ├── drawable-xxhdpi │ │ ├── ic_card_giftcard_white_24dp.png │ │ ├── ic_favorite_border_white_24dp.png │ │ ├── ic_favorite_white_24dp.png │ │ ├── ic_keyboard_arrow_down_white_24dp.png │ │ ├── ic_notifications_white_24dp.png │ │ ├── ic_refresh_white_24dp.png │ │ ├── ic_search_white_24dp.png │ │ ├── ic_sync_disabled_white_24dp.png │ │ ├── ic_sync_problem_white_24dp.png │ │ ├── ic_sync_white_24dp.png │ │ └── window_background_statusbar_toolbar_tab.9.png │ │ ├── drawable-xxxhdpi-v23 │ │ └── window_background_statusbar_toolbar_tab.9.png │ │ ├── drawable-xxxhdpi │ │ ├── ic_card_giftcard_white_24dp.png │ │ ├── ic_favorite_border_white_24dp.png │ │ ├── ic_favorite_white_24dp.png │ │ ├── ic_keyboard_arrow_down_white_24dp.png │ │ ├── ic_notifications_white_24dp.png │ │ ├── ic_refresh_white_24dp.png │ │ ├── ic_search_white_24dp.png │ │ ├── ic_sync_disabled_white_24dp.png │ │ ├── ic_sync_problem_white_24dp.png │ │ ├── ic_sync_white_24dp.png │ │ └── window_background_statusbar_toolbar_tab.9.png │ │ ├── drawable │ │ ├── bg_author_flag.xml │ │ ├── btn_jump_back_background.xml │ │ ├── count_background.xml │ │ ├── ic_account_circle.xml │ │ ├── ic_brightness_medium_outline.xml │ │ ├── ic_cancel_outline_black_24dp.xml │ │ ├── ic_comment_black_24dp.xml │ │ ├── ic_comment_outline.xml │ │ ├── ic_content_copy_outline_black_24dp.xml │ │ ├── ic_get_app_black_24dp.xml │ │ ├── ic_image_outline.xml │ │ ├── ic_info_outline_black_24dp.xml │ │ ├── ic_launcher_foreground.xml │ │ ├── ic_person_add_outline.xml │ │ ├── ic_share_black_24dp.xml │ │ ├── ic_star_black_24dp.xml │ │ ├── ic_star_border_black_24dp.xml │ │ ├── ic_undo_black_24dp.xml │ │ ├── ic_update_black_24dp.xml │ │ ├── img_topic_image_loading.xml │ │ └── radius_box.xml │ │ ├── layout │ │ ├── activity_loading.xml │ │ ├── activity_login.xml │ │ ├── activity_main.xml │ │ ├── activity_topic.xml │ │ ├── activity_topic_edit.xml │ │ ├── app_bar_layout.xml │ │ ├── container_fragment.xml │ │ ├── fragment_node_list.xml │ │ ├── fragment_notification_list.xml │ │ ├── fragment_topic.xml │ │ ├── fragment_topic_list.xml │ │ ├── layout_comment.xml │ │ ├── layout_topic.xml │ │ ├── model_comment_placeholder.xml │ │ ├── model_comments_footer.xml │ │ ├── model_postscript.xml │ │ ├── nav_header.xml │ │ ├── nav_view.xml │ │ ├── recycle_view.xml │ │ ├── tab_layout.xml │ │ ├── toolbar_action.xml │ │ ├── view_alert_dialog.xml │ │ ├── view_comment.xml │ │ ├── view_content_image.xml │ │ ├── view_content_text.xml │ │ ├── view_edittext.xml │ │ ├── view_float_topic.xml │ │ ├── view_node.xml │ │ ├── view_notification.xml │ │ ├── view_reply_form.xml │ │ ├── view_search_box.xml │ │ ├── view_select_node.xml │ │ └── view_topic.xml │ │ ├── menu │ │ ├── menu_comment.xml │ │ ├── menu_drawer.xml │ │ ├── menu_main.xml │ │ ├── menu_nodes.xml │ │ ├── menu_topic.xml │ │ ├── menu_topic_edit.xml │ │ └── menu_topic_list.xml │ │ ├── mipmap-anydpi-v26 │ │ ├── ic_launcher.xml │ │ └── ic_launcher_round.xml │ │ ├── mipmap-hdpi │ │ └── ic_launcher.png │ │ ├── mipmap-mdpi │ │ └── ic_launcher.png │ │ ├── mipmap-xhdpi │ │ └── ic_launcher.png │ │ ├── mipmap-xxhdpi │ │ └── ic_launcher.png │ │ ├── mipmap-xxxhdpi │ │ └── ic_launcher.png │ │ ├── raw │ │ └── all_nodes.json │ │ ├── values-night │ │ └── colors.xml │ │ ├── values-w820dp │ │ └── dimens.xml │ │ ├── values-zh │ │ ├── arrays.xml │ │ └── strings.xml │ │ ├── values │ │ ├── arrays.xml │ │ ├── colors.xml │ │ ├── config.xml │ │ ├── dimens.xml │ │ ├── ic_launcher_background.xml │ │ ├── ids.xml │ │ ├── intent_filter.xml │ │ ├── strings.xml │ │ ├── strings_activity_login.xml │ │ ├── strings_activity_settings.xml │ │ └── styles.xml │ │ └── xml │ │ └── pref_general.xml │ └── test │ └── kotlin │ └── com │ └── czbix │ └── v2ex │ └── util │ └── MiscUtilsTest.kt ├── build.gradle ├── gradle.properties ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat └── settings.gradle /.gitignore: -------------------------------------------------------------------------------- 1 | .gradle 2 | /local.properties 3 | /.idea/workspace.xml 4 | /.idea/libraries 5 | .DS_Store 6 | /build 7 | /captures 8 | /.navigation/ 9 | 10 | fabric.properties 11 | google-services.json 12 | 13 | # IDEA/Android Studio project files, because 14 | # the project can be imported from settings.gradle 15 | .idea 16 | *.iml 17 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # V2EX+ 2 | V2EX client for Android 3 | 4 | ## Get it 5 | [![Get it on Google Play](https://developer.android.com/images/brand/en_generic_rgb_wo_60.png)](https://play.google.com/store/apps/details?id=com.czbix.v2ex&referrer=utm_source%3Dgithub) 6 | -------------------------------------------------------------------------------- /app/.gitignore: -------------------------------------------------------------------------------- 1 | /build 2 | -------------------------------------------------------------------------------- /app/src/androidTest/kotlin/com/czbix/v2ex/ui/util/HtmlTest.kt: -------------------------------------------------------------------------------- 1 | package com.czbix.v2ex.ui.util 2 | 3 | import android.text.style.URLSpan 4 | import androidx.core.text.getSpans 5 | import io.kotlintest.inspectors.forAll 6 | import io.kotlintest.matchers.types.shouldNotBeSameInstanceAs 7 | import io.kotlintest.shouldBe 8 | import org.junit.Test 9 | 10 | class HtmlTest { 11 | @Test 12 | fun fromHtml_test1() { 13 | val html = """ 14 | startend 15 | """.trimIndent() 16 | 17 | val spanned = Html.fromHtml(html) 18 | val spans = spanned.getSpans() 19 | 20 | spans.forAll { 21 | it shouldNotBeSameInstanceAs URLSpan::class 22 | } 23 | } 24 | 25 | @Test 26 | fun fromHtml_test2() { 27 | val html = """ 28 | starthttps://i.loli.net/2019/08/16/NuzGcJbt4LTVSYB.jpgend 29 | """.trimIndent() 30 | 31 | val spanned = Html.fromHtml(html) 32 | 33 | spanned.length shouldBe 9 34 | } 35 | 36 | @Test 37 | fun fromHtml_withCfEncoded() { 38 | val html = """ 39 | 支持 [email protected][email protected] 超级慢动作视频 40 | """.trimIndent() 41 | 42 | val spanned = Html.fromHtml(html) 43 | 44 | spanned.toString() shouldBe "支持 720p@7680fps,1080p@960fps 超级慢动作视频" 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /app/src/debug/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 9 | 13 | 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /app/src/debug/kotlin/com/czbix/v2ex/DebugAppCtx.kt: -------------------------------------------------------------------------------- 1 | package com.czbix.v2ex 2 | 3 | import com.facebook.stetho.Stetho 4 | 5 | @Suppress("unused") 6 | class DebugAppCtx : AppCtx() { 7 | override fun init() { 8 | Stetho.initializeWithDefaults(this) 9 | 10 | super.init() 11 | } 12 | 13 | override val debugHelpers = DebugHelpersImpl() 14 | } -------------------------------------------------------------------------------- /app/src/debug/kotlin/com/czbix/v2ex/DebugHelpersImpl.kt: -------------------------------------------------------------------------------- 1 | package com.czbix.v2ex 2 | 3 | import com.facebook.stetho.okhttp3.StethoInterceptor 4 | import okhttp3.OkHttpClient 5 | 6 | class DebugHelpersImpl : DebugHelpers() { 7 | override fun addStethoInterceptor(builder: OkHttpClient.Builder): OkHttpClient.Builder { 8 | return builder.addNetworkInterceptor(StethoInterceptor()) 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /app/src/debug/res/values/config.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | AEdPqrEAAAAIDaR8KoBcaS0tYIoivJfM0RmqKr_lg1j-cAUtOg 4 | 5 | -------------------------------------------------------------------------------- /app/src/debug/res/values/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | V2EXD 4 | -------------------------------------------------------------------------------- /app/src/main/ic_launcher-web.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CzBiX/v2ex-android/e18b6722babeaf771a51c5b714d234edbf6f6fa1/app/src/main/ic_launcher-web.png -------------------------------------------------------------------------------- /app/src/main/java/com/czbix/v2ex/BackupAgent.java: -------------------------------------------------------------------------------- 1 | package com.czbix.v2ex; 2 | 3 | import android.app.backup.BackupAgentHelper; 4 | import android.app.backup.BackupHelper; 5 | import android.app.backup.SharedPreferencesBackupHelper; 6 | 7 | public class BackupAgent extends BackupAgentHelper { 8 | private static final String PREFS_BACKUP_KEY = "prefs"; 9 | 10 | private static final String PREFS_PREF = BuildConfig.APPLICATION_ID + "_preferences"; 11 | public void onCreate() { 12 | super.onCreate(); 13 | 14 | BackupHelper helper = new SharedPreferencesBackupHelper(this, PREFS_PREF); 15 | addHelper(PREFS_BACKUP_KEY, helper); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /app/src/main/java/com/czbix/v2ex/common/DeviceStatus.java: -------------------------------------------------------------------------------- 1 | package com.czbix.v2ex.common; 2 | 3 | import android.content.BroadcastReceiver; 4 | import android.content.Context; 5 | import android.content.Intent; 6 | import android.content.IntentFilter; 7 | import android.net.ConnectivityManager; 8 | import android.net.NetworkInfo; 9 | import androidx.core.net.ConnectivityManagerCompat; 10 | 11 | import com.czbix.v2ex.AppCtx; 12 | 13 | public class DeviceStatus { 14 | private static final DeviceStatus instance; 15 | 16 | private final ConnectivityManager mConnectivityManager; 17 | 18 | static { 19 | instance = new DeviceStatus(AppCtx.getInstance()); 20 | } 21 | 22 | public static DeviceStatus getInstance() { 23 | return instance; 24 | } 25 | 26 | private boolean mIsNetworkMetered; 27 | private boolean mIsNetworkConnected; 28 | 29 | DeviceStatus(Context context) { 30 | mConnectivityManager = ((ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE)); 31 | context.registerReceiver(new BroadcastReceiver() { 32 | @Override 33 | public void onReceive(Context context, Intent intent) { 34 | updateNetworkStatus(intent); 35 | } 36 | }, new IntentFilter(ConnectivityManager.CONNECTIVITY_ACTION)); 37 | 38 | updateNetworkStatus(null); 39 | } 40 | 41 | private void updateNetworkStatus(Intent intent) { 42 | mIsNetworkMetered = ConnectivityManagerCompat.isActiveNetworkMetered(mConnectivityManager); 43 | 44 | if (intent == null) { 45 | final NetworkInfo activeNetworkInfo = mConnectivityManager.getActiveNetworkInfo(); 46 | if (activeNetworkInfo == null) { 47 | mIsNetworkConnected = false; 48 | } else { 49 | mIsNetworkConnected = activeNetworkInfo.isConnected(); 50 | } 51 | } else { 52 | mIsNetworkConnected = !intent.getBooleanExtra(ConnectivityManager.EXTRA_NO_CONNECTIVITY, false); 53 | } 54 | } 55 | 56 | public boolean isNetworkMetered() { 57 | return mIsNetworkMetered; 58 | } 59 | 60 | public boolean isNetworkConnected() { 61 | return mIsNetworkConnected; 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /app/src/main/java/com/czbix/v2ex/common/exception/ExIllegalStateException.java: -------------------------------------------------------------------------------- 1 | package com.czbix.v2ex.common.exception; 2 | 3 | public class ExIllegalStateException extends java.lang.IllegalStateException { 4 | public boolean shouldLogged; 5 | 6 | public ExIllegalStateException() { 7 | super(); 8 | } 9 | 10 | public ExIllegalStateException(Throwable cause) { 11 | super(cause); 12 | } 13 | 14 | public ExIllegalStateException(String detailMessage) { 15 | super(detailMessage); 16 | } 17 | 18 | public ExIllegalStateException(String message, Throwable cause) { 19 | super(message, cause); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /app/src/main/java/com/czbix/v2ex/common/exception/FatalException.java: -------------------------------------------------------------------------------- 1 | package com.czbix.v2ex.common.exception; 2 | 3 | public class FatalException extends RuntimeException { 4 | public FatalException(String detailMessage) { 5 | super(detailMessage); 6 | } 7 | 8 | public FatalException(String detailMessage, Throwable throwable) { 9 | super(detailMessage, throwable); 10 | } 11 | 12 | public FatalException(Throwable throwable) { 13 | super(throwable); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /app/src/main/java/com/czbix/v2ex/common/exception/NotImplementedException.java: -------------------------------------------------------------------------------- 1 | package com.czbix.v2ex.common.exception; 2 | 3 | public class NotImplementedException extends FatalException { 4 | public NotImplementedException() { 5 | super("This method not implemented yet!"); 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /app/src/main/java/com/czbix/v2ex/common/exception/RemoteException.java: -------------------------------------------------------------------------------- 1 | package com.czbix.v2ex.common.exception; 2 | 3 | 4 | import okhttp3.Response; 5 | 6 | public class RemoteException extends Exception { 7 | private int mCode; 8 | 9 | public RemoteException(Response response) { 10 | this(response, null); 11 | } 12 | 13 | public RemoteException(Response response, Throwable tr) { 14 | super("remote failed with code: " + response.code(), tr); 15 | mCode = response.code(); 16 | } 17 | 18 | public int getCode() { 19 | return mCode; 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /app/src/main/java/com/czbix/v2ex/common/exception/RequestException.java: -------------------------------------------------------------------------------- 1 | package com.czbix.v2ex.common.exception; 2 | 3 | import com.google.common.base.Strings; 4 | import com.google.common.net.HttpHeaders; 5 | 6 | import okhttp3.Response; 7 | 8 | public class RequestException extends RuntimeException { 9 | private final Response mResponse; 10 | private String errorHtml; 11 | private boolean mShouldLogged; 12 | 13 | public RequestException(Response response) { 14 | this(null, response); 15 | } 16 | 17 | public RequestException(Response response, Throwable tr) { 18 | this(null, response, tr); 19 | } 20 | 21 | public RequestException(String message, Response response) { 22 | this(message, response, null); 23 | } 24 | 25 | public RequestException(String message, Response response, Throwable tr) { 26 | super(message, tr); 27 | 28 | mShouldLogged = true; 29 | mResponse = response; 30 | } 31 | 32 | /** 33 | * error info in html 34 | */ 35 | public String getErrorHtml() { 36 | return errorHtml; 37 | } 38 | 39 | public void setErrorHtml(String errorHtml) { 40 | this.errorHtml = errorHtml; 41 | } 42 | 43 | public Response getResponse() { 44 | return mResponse; 45 | } 46 | 47 | public int getCode() { 48 | return mResponse.code(); 49 | } 50 | 51 | public boolean isShouldLogged() { 52 | return mShouldLogged; 53 | } 54 | 55 | public void setShouldLogged(boolean shouldLogged) { 56 | mShouldLogged = shouldLogged; 57 | } 58 | 59 | @Override 60 | public String getMessage() { 61 | final String message = Strings.nullToEmpty(super.getMessage()); 62 | final StringBuilder sb = new StringBuilder(message); 63 | 64 | sb.append(", url: ").append(mResponse.request().url()); 65 | sb.append(", code: ").append(mResponse.code()); 66 | 67 | if (mResponse.isRedirect()) { 68 | sb.append(", location: "); 69 | sb.append(mResponse.header(HttpHeaders.LOCATION)); 70 | } 71 | 72 | return sb.toString(); 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /app/src/main/java/com/czbix/v2ex/common/exception/UnauthorizedException.java: -------------------------------------------------------------------------------- 1 | package com.czbix.v2ex.common.exception; 2 | 3 | 4 | import okhttp3.Response; 5 | 6 | public class UnauthorizedException extends RequestException { 7 | public UnauthorizedException(Response response) { 8 | super(response); 9 | } 10 | 11 | public UnauthorizedException(Response response, Throwable tr) { 12 | super(response, tr); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /app/src/main/java/com/czbix/v2ex/dao/DaoBase.java: -------------------------------------------------------------------------------- 1 | package com.czbix.v2ex.dao; 2 | 3 | import android.database.sqlite.SQLiteDatabase; 4 | 5 | public abstract class DaoBase { 6 | protected static T execute(SqlOperation operation) { 7 | return execute(operation, false); 8 | } 9 | 10 | protected static synchronized T execute(SqlOperation operation, boolean isWrite) { 11 | SQLiteDatabase db = null; 12 | try { 13 | final V2exDb instance = V2exDb.getInstance(); 14 | db = isWrite ? instance.getWritableDatabase() : instance.getReadableDatabase(); 15 | return operation.execute(db); 16 | } finally { 17 | if (db != null) { 18 | db.close(); 19 | } 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /app/src/main/java/com/czbix/v2ex/dao/SqlOperation.java: -------------------------------------------------------------------------------- 1 | package com.czbix.v2ex.dao; 2 | 3 | import android.database.sqlite.SQLiteDatabase; 4 | 5 | public abstract class SqlOperation { 6 | public abstract T execute(SQLiteDatabase db); 7 | } 8 | -------------------------------------------------------------------------------- /app/src/main/java/com/czbix/v2ex/dao/V2exDb.java: -------------------------------------------------------------------------------- 1 | package com.czbix.v2ex.dao; 2 | 3 | import android.content.Context; 4 | import android.database.sqlite.SQLiteDatabase; 5 | import android.database.sqlite.SQLiteOpenHelper; 6 | 7 | import com.czbix.v2ex.AppCtx; 8 | import com.czbix.v2ex.util.LogUtils; 9 | import com.google.common.base.Preconditions; 10 | 11 | public class V2exDb extends SQLiteOpenHelper { 12 | private static final String TAG = V2exDb.class.getSimpleName(); 13 | private static final String DB_NAME = "v2ex.db"; 14 | private static final int CURRENT_VERSION = 4; 15 | 16 | private static final V2exDb INSTANCE; 17 | 18 | static { 19 | INSTANCE = new V2exDb(AppCtx.getInstance()); 20 | } 21 | 22 | public static V2exDb getInstance() { 23 | return INSTANCE; 24 | } 25 | 26 | public V2exDb(Context context) { 27 | super(context, DB_NAME, null, CURRENT_VERSION); 28 | } 29 | 30 | @Override 31 | public void onCreate(SQLiteDatabase db) { 32 | ConfigDao.createTable(db); 33 | NodeDao.createTable(db); 34 | } 35 | 36 | @Override 37 | public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) { 38 | if (oldVersion == 1) { 39 | LogUtils.i(TAG, "upgrade database from %d to %d", oldVersion, newVersion); 40 | oldVersion = 2; 41 | } 42 | if (oldVersion == 2) { 43 | LogUtils.i(TAG, "upgrade database from %d to %d", oldVersion, newVersion); 44 | oldVersion = 3; 45 | } 46 | if (oldVersion == 3) { 47 | LogUtils.i(TAG, "upgrade database from %d to %d", oldVersion, newVersion); 48 | db.execSQL("DROP TABLE topic;"); 49 | db.execSQL("DROP TABLE draft;"); 50 | oldVersion = 4; 51 | } 52 | 53 | Preconditions.checkState(oldVersion == newVersion, "old version not match new version"); 54 | } 55 | 56 | /** 57 | * it may use a lot of time 58 | */ 59 | public void init() { 60 | getWritableDatabase(); 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /app/src/main/java/com/czbix/v2ex/eventbus/LoginEvent.java: -------------------------------------------------------------------------------- 1 | package com.czbix.v2ex.eventbus; 2 | 3 | import com.czbix.v2ex.event.BaseEvent; 4 | 5 | public class LoginEvent extends BaseEvent { 6 | public final String mUsername; 7 | 8 | public LoginEvent() { 9 | mUsername = null; 10 | } 11 | 12 | public LoginEvent(String username) { 13 | mUsername = username; 14 | } 15 | 16 | public boolean isLogOut() { 17 | return mUsername == null; 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /app/src/main/java/com/czbix/v2ex/eventbus/executor/HandlerExecutor.java: -------------------------------------------------------------------------------- 1 | package com.czbix.v2ex.eventbus.executor; 2 | 3 | import androidx.annotation.NonNull; 4 | 5 | import com.czbix.v2ex.util.ExecutorUtils; 6 | 7 | import java.util.concurrent.Executor; 8 | 9 | public class HandlerExecutor implements Executor { 10 | @Override 11 | public void execute(@NonNull Runnable command) { 12 | ExecutorUtils.runInUiThread(command); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /app/src/main/java/com/czbix/v2ex/helper/MultiList.java: -------------------------------------------------------------------------------- 1 | package com.czbix.v2ex.helper; 2 | 3 | import com.google.common.base.Preconditions; 4 | import com.google.common.collect.Lists; 5 | 6 | import java.util.AbstractList; 7 | import java.util.Collections; 8 | import java.util.List; 9 | 10 | public class MultiList extends AbstractList { 11 | private final List> mList; 12 | 13 | public MultiList() { 14 | mList = Lists.newArrayList(); 15 | } 16 | 17 | @SafeVarargs 18 | public MultiList(List... data) { 19 | mList = Lists.newArrayListWithCapacity(data.length); 20 | Collections.addAll(mList, data); 21 | } 22 | 23 | public void addList(List data) { 24 | mList.add(data); 25 | } 26 | 27 | public void setList(int index, List data) { 28 | Preconditions.checkElementIndex(index, mList.size()); 29 | 30 | mList.set(index, data); 31 | } 32 | 33 | public int listSize() { 34 | return mList.size(); 35 | } 36 | 37 | @Override 38 | public void clear() { 39 | mList.clear(); 40 | } 41 | 42 | @Override 43 | public E get(int location) { 44 | int index = location; 45 | 46 | for (List list : mList) { 47 | if (list.size() <= location) { 48 | location -= list.size(); 49 | continue; 50 | } 51 | 52 | return list.get(location); 53 | } 54 | 55 | throw new IndexOutOfBoundsException("Invalid index " + index + ", size is " + size()); 56 | } 57 | 58 | @Override 59 | public int size() { 60 | int size = 0; 61 | for (List list : mList) { 62 | size += list.size(); 63 | } 64 | 65 | return size; 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /app/src/main/java/com/czbix/v2ex/model/Favable.java: -------------------------------------------------------------------------------- 1 | package com.czbix.v2ex.model; 2 | 3 | public interface Favable { 4 | String getFavUrl(String token); 5 | String getUnFavUrl(String token); 6 | } 7 | -------------------------------------------------------------------------------- /app/src/main/java/com/czbix/v2ex/model/Ignorable.java: -------------------------------------------------------------------------------- 1 | package com.czbix.v2ex.model; 2 | 3 | public interface Ignorable { 4 | String getIgnoreUrl(); 5 | } 6 | -------------------------------------------------------------------------------- /app/src/main/java/com/czbix/v2ex/model/LoginResult.java: -------------------------------------------------------------------------------- 1 | package com.czbix.v2ex.model; 2 | 3 | public class LoginResult { 4 | public final Avatar mAvatar; 5 | public final String mUsername; 6 | 7 | public LoginResult(String username, Avatar avatar) { 8 | mAvatar = avatar; 9 | mUsername = username; 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /app/src/main/java/com/czbix/v2ex/model/Notification.java: -------------------------------------------------------------------------------- 1 | package com.czbix.v2ex.model; 2 | 3 | import androidx.annotation.IntDef; 4 | 5 | import com.czbix.v2ex.db.Member; 6 | import com.google.common.base.Objects; 7 | 8 | import java.lang.annotation.Retention; 9 | import java.lang.annotation.RetentionPolicy; 10 | 11 | public class Notification { 12 | @Retention(RetentionPolicy.SOURCE) 13 | @IntDef({TYPE_UNKNOWN, TYPE_THANK_TOPIC, TYPE_REPLY_TOPIC, TYPE_FAV_TOPIC, TYPE_THANK_COMMENT, TYPE_REPLY_COMMENT}) 14 | public @interface NotificationType {} 15 | 16 | public static final int TYPE_UNKNOWN = 0; 17 | public static final int TYPE_THANK_TOPIC = 1; 18 | public static final int TYPE_REPLY_TOPIC = 2; 19 | public static final int TYPE_FAV_TOPIC = 3; 20 | public static final int TYPE_THANK_COMMENT = 4; 21 | public static final int TYPE_REPLY_COMMENT = 5; 22 | 23 | public final Member mMember; 24 | @NotificationType 25 | public final int mType; 26 | public final Topic mTopic; 27 | public final String mContent; 28 | public final String mTime; 29 | 30 | public Notification(Member member, Topic topic, @NotificationType int type, String content, String time) { 31 | mContent = content; 32 | mMember = member; 33 | mType = type; 34 | mTopic = topic; 35 | mTime = time; 36 | } 37 | 38 | @Override 39 | public boolean equals(Object o) { 40 | if (this == o) return true; 41 | if (!(o instanceof Notification)) return false; 42 | Notification that = (Notification) o; 43 | return Objects.equal(mType, that.mType) && 44 | Objects.equal(mMember, that.mMember) && 45 | Objects.equal(mTopic, that.mTopic) && 46 | Objects.equal(mContent, that.mContent) && 47 | Objects.equal(mTime, that.mTime); 48 | } 49 | 50 | @Override 51 | public int hashCode() { 52 | return Objects.hashCode(mMember, mType, mTopic, mContent, mTime); 53 | } 54 | 55 | public static class Builder { 56 | private Member mMember; 57 | private Topic mTopic; 58 | private int mType; 59 | private String mContent; 60 | private String mTime; 61 | 62 | public Builder setMember(Member member) { 63 | mMember = member; 64 | return this; 65 | } 66 | 67 | public Builder setTopic(Topic topic) { 68 | mTopic = topic; 69 | return this; 70 | } 71 | 72 | public Builder setType(@NotificationType int type) { 73 | mType = type; 74 | return this; 75 | } 76 | 77 | public Builder setContent(String content) { 78 | mContent = content; 79 | return this; 80 | } 81 | 82 | public Builder setTime(String time) { 83 | mTime = time; 84 | return this; 85 | } 86 | 87 | public Notification createNotification() { 88 | return new Notification(mMember, mTopic, mType, mContent, mTime); 89 | } 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /app/src/main/java/com/czbix/v2ex/model/Page.java: -------------------------------------------------------------------------------- 1 | package com.czbix.v2ex.model; 2 | 3 | import android.os.Parcel; 4 | import android.os.Parcelable; 5 | 6 | import com.czbix.v2ex.AppCtx; 7 | import com.czbix.v2ex.R; 8 | import com.czbix.v2ex.network.RequestHelper; 9 | 10 | public abstract class Page implements Parcelable { 11 | public static final Page PAGE_FAV_TOPIC = new SimplePage( 12 | AppCtx.getInstance().getString(R.string.title_fragment_favorite), "/my/topics"); 13 | 14 | public abstract String getTitle(); 15 | 16 | public abstract String getUrl(); 17 | 18 | @Override 19 | public int describeContents() { 20 | return 0; 21 | } 22 | 23 | @Override 24 | public String toString() { 25 | return getTitle(); 26 | } 27 | 28 | @Override 29 | public boolean equals(Object o) { 30 | if (this == o) return true; 31 | if (!(o instanceof Page)) return false; 32 | Page page = (Page) o; 33 | return getUrl().equals(page.getUrl()); 34 | } 35 | 36 | @Override 37 | public int hashCode() { 38 | return getUrl().hashCode(); 39 | } 40 | 41 | public static class SimplePage extends Page { 42 | private final String mTitle; 43 | private final String mUrl; 44 | 45 | public SimplePage(String title, String url) { 46 | mTitle = title; 47 | mUrl = url; 48 | } 49 | 50 | @Override 51 | public String getTitle() { 52 | return mTitle; 53 | } 54 | 55 | @Override 56 | public String getUrl() { 57 | return RequestHelper.BASE_URL + mUrl; 58 | } 59 | 60 | @Override 61 | public void writeToParcel(Parcel dest, int flags) { 62 | dest.writeString(this.mTitle); 63 | dest.writeString(this.mUrl); 64 | } 65 | 66 | protected SimplePage(Parcel in) { 67 | this.mTitle = in.readString(); 68 | this.mUrl = in.readString(); 69 | } 70 | 71 | public static final Creator CREATOR = new Creator() { 72 | public SimplePage createFromParcel(Parcel source) { 73 | return new SimplePage(source); 74 | } 75 | 76 | public SimplePage[] newArray(int size) { 77 | return new SimplePage[size]; 78 | } 79 | }; 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /app/src/main/java/com/czbix/v2ex/model/Thankable.java: -------------------------------------------------------------------------------- 1 | package com.czbix.v2ex.model; 2 | 3 | public interface Thankable { 4 | String getThankUrl(); 5 | } 6 | -------------------------------------------------------------------------------- /app/src/main/java/com/czbix/v2ex/model/db/Draft.java: -------------------------------------------------------------------------------- 1 | package com.czbix.v2ex.model.db; 2 | 3 | import java.util.concurrent.TimeUnit; 4 | 5 | public class Draft { 6 | public final long mId; 7 | public final int mTopicId; 8 | public final String mContent; 9 | public final long mTime; 10 | 11 | public Draft(long id, int topicId, String content, long time) { 12 | mId = id; 13 | mTopicId = topicId; 14 | mContent = content; 15 | mTime = time; 16 | } 17 | 18 | public boolean isExpired() { 19 | return isExpired(mTime); 20 | } 21 | 22 | public static boolean isExpired(long draftTime) { 23 | return TimeUnit.MILLISECONDS.toDays(System.currentTimeMillis() - draftTime) > 1; 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /app/src/main/java/com/czbix/v2ex/model/db/TopicDraft.java: -------------------------------------------------------------------------------- 1 | package com.czbix.v2ex.model.db; 2 | 3 | import com.czbix.v2ex.util.GsonUtilsKt; 4 | 5 | public class TopicDraft { 6 | public final String mNodeName; 7 | public final String mTitle; 8 | public final String mContent; 9 | 10 | public TopicDraft(String nodeName, String title, String content) { 11 | mNodeName = nodeName; 12 | mTitle = title; 13 | mContent = content; 14 | } 15 | 16 | public String toJson() { 17 | return GsonUtilsKt.getGSON().toJson(this); 18 | } 19 | 20 | public static TopicDraft fromJson(String str) { 21 | return GsonUtilsKt.getGSON().fromJson(str, TopicDraft.class); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /app/src/main/java/com/czbix/v2ex/model/db/ViewHistory.java: -------------------------------------------------------------------------------- 1 | package com.czbix.v2ex.model.db; 2 | 3 | import com.czbix.v2ex.model.Topic; 4 | 5 | public class ViewHistory { 6 | public final Topic mTopic; 7 | /** in milliseconds */ 8 | public final long mTime; 9 | 10 | public ViewHistory(Topic topic, long time) { 11 | mTime = time; 12 | mTopic = topic; 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /app/src/main/java/com/czbix/v2ex/network/Etag.java: -------------------------------------------------------------------------------- 1 | package com.czbix.v2ex.network; 2 | 3 | import com.google.common.base.Preconditions; 4 | 5 | public class Etag { 6 | public final String mOldEtag; 7 | private String mNewEtag; 8 | 9 | public Etag(String etag) { 10 | mOldEtag = etag; 11 | } 12 | 13 | /** 14 | * @return etag is modified 15 | */ 16 | public boolean setNewEtag(String etag) { 17 | Preconditions.checkNotNull(etag); 18 | 19 | mNewEtag = etag; 20 | 21 | return isModified(); 22 | } 23 | 24 | public boolean isModified() { 25 | return !mNewEtag.equals(mOldEtag); 26 | } 27 | 28 | public String getNewEtag() { 29 | return mNewEtag; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /app/src/main/java/com/czbix/v2ex/network/HttpStatus.java: -------------------------------------------------------------------------------- 1 | package com.czbix.v2ex.network; 2 | 3 | @SuppressWarnings("unused") 4 | public interface HttpStatus { 5 | int SC_ACCEPTED = 202; 6 | int SC_BAD_GATEWAY = 502; 7 | int SC_BAD_REQUEST = 400; 8 | int SC_CONFLICT = 409; 9 | int SC_CONTINUE = 100; 10 | int SC_CREATED = 201; 11 | int SC_EXPECTATION_FAILED = 417; 12 | int SC_FAILED_DEPENDENCY = 424; 13 | int SC_FORBIDDEN = 403; 14 | int SC_GATEWAY_TIMEOUT = 504; 15 | int SC_GONE = 410; 16 | int SC_HTTP_VERSION_NOT_SUPPORTED = 505; 17 | int SC_INSUFFICIENT_SPACE_ON_RESOURCE = 419; 18 | int SC_INSUFFICIENT_STORAGE = 507; 19 | int SC_INTERNAL_SERVER_ERROR = 500; 20 | int SC_LENGTH_REQUIRED = 411; 21 | int SC_LOCKED = 423; 22 | int SC_METHOD_FAILURE = 420; 23 | int SC_METHOD_NOT_ALLOWED = 405; 24 | int SC_MOVED_PERMANENTLY = 301; 25 | int SC_MOVED_TEMPORARILY = 302; 26 | int SC_MULTIPLE_CHOICES = 300; 27 | int SC_MULTI_STATUS = 207; 28 | int SC_NON_AUTHORITATIVE_INFORMATION = 203; 29 | int SC_NOT_ACCEPTABLE = 406; 30 | int SC_NOT_FOUND = 404; 31 | int SC_NOT_IMPLEMENTED = 501; 32 | int SC_NOT_MODIFIED = 304; 33 | int SC_NO_CONTENT = 204; 34 | int SC_OK = 200; 35 | int SC_PARTIAL_CONTENT = 206; 36 | int SC_PAYMENT_REQUIRED = 402; 37 | int SC_PRECONDITION_FAILED = 412; 38 | int SC_PROCESSING = 102; 39 | int SC_PROXY_AUTHENTICATION_REQUIRED = 407; 40 | int SC_REQUESTED_RANGE_NOT_SATISFIABLE = 416; 41 | int SC_REQUEST_TIMEOUT = 408; 42 | int SC_REQUEST_TOO_LONG = 413; 43 | int SC_REQUEST_URI_TOO_LONG = 414; 44 | int SC_RESET_CONTENT = 205; 45 | int SC_SEE_OTHER = 303; 46 | int SC_SERVICE_UNAVAILABLE = 503; 47 | int SC_SWITCHING_PROTOCOLS = 101; 48 | int SC_TEMPORARY_REDIRECT = 307; 49 | int SC_UNAUTHORIZED = 401; 50 | int SC_UNPROCESSABLE_ENTITY = 422; 51 | int SC_UNSUPPORTED_MEDIA_TYPE = 415; 52 | int SC_USE_PROXY = 305; 53 | } 54 | 55 | -------------------------------------------------------------------------------- /app/src/main/java/com/czbix/v2ex/ui/LoadingActivity.java: -------------------------------------------------------------------------------- 1 | package com.czbix.v2ex.ui; 2 | 3 | import android.os.Bundle; 4 | import androidx.appcompat.app.AppCompatActivity; 5 | import android.widget.Toast; 6 | 7 | import com.czbix.v2ex.R; 8 | 9 | public class LoadingActivity extends AppCompatActivity { 10 | @Override 11 | protected void onCreate(Bundle savedInstanceState) { 12 | super.onCreate(savedInstanceState); 13 | setContentView(R.layout.activity_loading); 14 | } 15 | 16 | @Override 17 | public void onBackPressed() { 18 | Toast.makeText(this, R.string.toast_please_wait, Toast.LENGTH_SHORT).show(); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /app/src/main/java/com/czbix/v2ex/ui/adapter/StableArrayAdapter.java: -------------------------------------------------------------------------------- 1 | package com.czbix.v2ex.ui.adapter; 2 | 3 | import android.content.Context; 4 | import android.widget.ArrayAdapter; 5 | 6 | import com.czbix.v2ex.util.MiscUtils; 7 | import com.google.common.collect.Maps; 8 | 9 | import java.util.HashMap; 10 | import java.util.List; 11 | 12 | public class StableArrayAdapter extends ArrayAdapter { 13 | 14 | final int INVALID_ID = -1; 15 | 16 | HashMap mIdMap = Maps.newHashMap(); 17 | 18 | public StableArrayAdapter(Context context, int textViewResourceId, List objects) { 19 | super(context, textViewResourceId, objects); 20 | for (int i = 0; i < objects.size(); ++i) { 21 | mIdMap.put(objects.get(i), i); 22 | } 23 | } 24 | 25 | @Override 26 | public long getItemId(int position) { 27 | if (position < 0 || position >= mIdMap.size()) { 28 | return INVALID_ID; 29 | } 30 | T item = getItem(position); 31 | return mIdMap.get(item); 32 | } 33 | 34 | @Override 35 | public boolean hasStableIds() { 36 | // see http://stackoverflow.com/questions/26648991/ 37 | return false; 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /app/src/main/java/com/czbix/v2ex/ui/fragment/BaseTabFragment.java: -------------------------------------------------------------------------------- 1 | package com.czbix.v2ex.ui.fragment; 2 | 3 | import android.os.Bundle; 4 | import com.google.android.material.tabs.TabLayout; 5 | import androidx.fragment.app.Fragment; 6 | import androidx.fragment.app.FragmentManager; 7 | import androidx.fragment.app.FragmentPagerAdapter; 8 | import androidx.core.view.ViewCompat; 9 | import androidx.viewpager.widget.ViewPager; 10 | import android.view.LayoutInflater; 11 | import android.view.View; 12 | import android.view.ViewGroup; 13 | 14 | import com.czbix.v2ex.R; 15 | 16 | public abstract class BaseTabFragment extends Fragment { 17 | protected TabLayout mTabLayout; 18 | 19 | public BaseTabFragment() { 20 | // Required empty public constructor 21 | } 22 | 23 | @Override 24 | public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { 25 | final View view = inflater.inflate(R.layout.tab_layout, container, false); 26 | ViewPager viewPager = ((ViewPager) view.findViewById(R.id.view_pager)); 27 | FragmentPagerAdapter adapter = getAdapter(getChildFragmentManager()); 28 | viewPager.setAdapter(adapter); 29 | viewPager.setPageMargin(getResources().getDimensionPixelSize(R.dimen.activity_horizontal_margin)); 30 | 31 | mTabLayout = (TabLayout) view.findViewById(R.id.tab_layout); 32 | ViewCompat.setElevation(mTabLayout, getResources().getDimension(R.dimen.appbar_elevation)); 33 | mTabLayout.setupWithViewPager(viewPager); 34 | 35 | return view; 36 | } 37 | 38 | protected abstract FragmentPagerAdapter getAdapter(FragmentManager manager); 39 | } 40 | -------------------------------------------------------------------------------- /app/src/main/java/com/czbix/v2ex/ui/fragment/FavoriteTabFragment.java: -------------------------------------------------------------------------------- 1 | package com.czbix.v2ex.ui.fragment; 2 | 3 | import android.os.Bundle; 4 | import androidx.fragment.app.Fragment; 5 | import androidx.fragment.app.FragmentManager; 6 | import androidx.fragment.app.FragmentPagerAdapter; 7 | 8 | import com.czbix.v2ex.R; 9 | import com.czbix.v2ex.model.Page; 10 | import com.czbix.v2ex.ui.MainActivity; 11 | 12 | public class FavoriteTabFragment extends BaseTabFragment { 13 | public static FavoriteTabFragment newInstance() { 14 | return new FavoriteTabFragment(); 15 | } 16 | 17 | @Override 18 | protected FragmentPagerAdapter getAdapter(FragmentManager manager) { 19 | return new FavoriteFragmentAdapter(manager); 20 | } 21 | 22 | @Override 23 | public void onActivityCreated(Bundle savedInstanceState) { 24 | super.onActivityCreated(savedInstanceState); 25 | 26 | final MainActivity activity = (MainActivity) getActivity(); 27 | activity.setTitle(R.string.drawer_favorite); 28 | activity.setNavSelected(R.id.drawer_favorite); 29 | } 30 | 31 | private class FavoriteFragmentAdapter extends FragmentPagerAdapter { 32 | public FavoriteFragmentAdapter(FragmentManager manager) { 33 | super(manager); 34 | } 35 | 36 | public Fragment getItem(int position) { 37 | return position == 0 ? FavNodeFragment.newInstance() 38 | : TopicListFragment.Companion.newInstance(Page.PAGE_FAV_TOPIC); 39 | } 40 | 41 | @Override 42 | public int getCount() { 43 | return 2; 44 | } 45 | 46 | @Override 47 | public CharSequence getPageTitle(int position) { 48 | return getString(position == 0 ? R.string.title_fragment_nodes : R.string.title_fragment_topics); 49 | } 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /app/src/main/java/com/czbix/v2ex/ui/helper/ForceTouchDetector.java: -------------------------------------------------------------------------------- 1 | package com.czbix.v2ex.ui.helper; 2 | 3 | import androidx.annotation.NonNull; 4 | import android.view.MotionEvent; 5 | 6 | public class ForceTouchDetector { 7 | private boolean mInForceTouch; 8 | private final Runnable mOnStartListener; 9 | private final Runnable mOnStopListener; 10 | 11 | public ForceTouchDetector(@NonNull Runnable onStartListener, 12 | @NonNull Runnable onStopListener) { 13 | mOnStartListener = onStartListener; 14 | mOnStopListener = onStopListener; 15 | } 16 | 17 | public boolean handleEvent(MotionEvent e) { 18 | if (e.getActionIndex() != 0) { 19 | // handle first pointer only 20 | return false; 21 | } 22 | 23 | final int action = e.getActionMasked(); 24 | final float pressure = e.getPressure(); 25 | 26 | if (action == MotionEvent.ACTION_UP || action == MotionEvent.ACTION_CANCEL) { 27 | if (mInForceTouch) { 28 | onStop(); 29 | e.setAction(MotionEvent.ACTION_CANCEL); 30 | return false; 31 | } 32 | } 33 | 34 | if (pressure < 0.8) { 35 | if (mInForceTouch) { 36 | if (pressure < 0.7) { 37 | onStop(); 38 | e.setAction(MotionEvent.ACTION_CANCEL); 39 | return false; 40 | } 41 | } else { 42 | return false; 43 | } 44 | } 45 | 46 | if (action == MotionEvent.ACTION_MOVE) { 47 | onStart(); 48 | return true; 49 | } else { 50 | if (mInForceTouch) { 51 | onStop(); 52 | return true; 53 | } else { 54 | return false; 55 | } 56 | } 57 | } 58 | 59 | private void onStart() { 60 | if (mInForceTouch) { 61 | return; 62 | } 63 | 64 | mInForceTouch = true; 65 | mOnStartListener.run(); 66 | } 67 | 68 | private void onStop() { 69 | if (!mInForceTouch) { 70 | return; 71 | } 72 | 73 | mInForceTouch = false; 74 | mOnStopListener.run(); 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /app/src/main/java/com/czbix/v2ex/ui/loader/AsyncTaskLoader.java: -------------------------------------------------------------------------------- 1 | package com.czbix.v2ex.ui.loader; 2 | 3 | import android.content.Context; 4 | 5 | import com.czbix.v2ex.util.LogUtils; 6 | 7 | import java.util.concurrent.TimeUnit; 8 | 9 | public abstract class AsyncTaskLoader extends androidx.loader.content.AsyncTaskLoader> { 10 | private static final String TAG = AsyncTaskLoader.class.getSimpleName(); 11 | 12 | private static final long DEFAULT_UPDATE_THROTTLE = TimeUnit.SECONDS.toMillis(3); 13 | protected LoaderResult mResult; 14 | 15 | public AsyncTaskLoader(Context context) { 16 | super(context); 17 | 18 | setUpdateThrottle(DEFAULT_UPDATE_THROTTLE); 19 | } 20 | 21 | @Override 22 | protected void onStartLoading() { 23 | super.onStartLoading(); 24 | 25 | if (mResult != null && isStarted()) { 26 | deliverResult(mResult); 27 | } 28 | 29 | if (takeContentChanged() || mResult == null) { 30 | forceLoad(); 31 | } 32 | } 33 | 34 | @Override 35 | protected void onStopLoading() { 36 | super.onStopLoading(); 37 | 38 | cancelLoad(); 39 | } 40 | 41 | @Override 42 | protected void onReset() { 43 | super.onReset(); 44 | 45 | stopLoading(); 46 | 47 | mResult = null; 48 | } 49 | 50 | /** 51 | * please override {@link #loadInBackgroundWithException()} 52 | */ 53 | @Override 54 | public LoaderResult loadInBackground() { 55 | LoaderResult loaderResult; 56 | try { 57 | T result = loadInBackgroundWithException(); 58 | loaderResult = new LoaderResult<>(result); 59 | } catch (Exception e) { 60 | LogUtils.d(TAG, "async task loader has exception", e); 61 | loaderResult = new LoaderResult<>(e); 62 | } 63 | 64 | mResult = loaderResult; 65 | return mResult; 66 | } 67 | 68 | public abstract T loadInBackgroundWithException() throws Exception; 69 | 70 | /** 71 | * used to wrap data with exception 72 | * @param result type 73 | */ 74 | public static class LoaderResult { 75 | public final Exception mException; 76 | public final T mResult; 77 | 78 | public LoaderResult(Exception exception) { 79 | mException = exception; 80 | mResult = null; 81 | } 82 | 83 | public LoaderResult(T result) { 84 | mResult = result; 85 | mException = null; 86 | } 87 | 88 | public boolean hasException() { 89 | return mException != null; 90 | } 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /app/src/main/java/com/czbix/v2ex/ui/loader/NotificationLoader.java: -------------------------------------------------------------------------------- 1 | package com.czbix.v2ex.ui.loader; 2 | 3 | import android.content.Context; 4 | 5 | import com.czbix.v2ex.model.Notification; 6 | import com.czbix.v2ex.network.RequestHelper; 7 | 8 | import java.util.List; 9 | 10 | public class NotificationLoader extends AsyncTaskLoader> { 11 | public NotificationLoader(Context context) { 12 | super(context); 13 | } 14 | 15 | @Override 16 | public List loadInBackgroundWithException() throws Exception { 17 | return RequestHelper.INSTANCE.getNotifications(); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /app/src/main/java/com/czbix/v2ex/ui/model/EpoxyDataBindingConfig.java: -------------------------------------------------------------------------------- 1 | package com.czbix.v2ex.ui.model; 2 | 3 | import com.airbnb.epoxy.EpoxyDataBindingPattern; 4 | import com.czbix.v2ex.R; 5 | 6 | @SuppressWarnings("unused") 7 | @EpoxyDataBindingPattern(rClass = R.class, layoutPrefix = "model_") 8 | interface EpoxyDataBindingConfig {} 9 | -------------------------------------------------------------------------------- /app/src/main/java/com/czbix/v2ex/ui/preference/TabListPreference.java: -------------------------------------------------------------------------------- 1 | package com.czbix.v2ex.ui.preference; 2 | 3 | /* 4 | public class TabListPreference extends DialogPreference { 5 | public List mTabsToShow; 6 | 7 | public TabListPreference(Context context, AttributeSet attrs) { 8 | super(context, attrs); 9 | } 10 | 11 | 12 | 13 | 14 | 15 | @Override 16 | protected void onSetInitialValue(boolean restorePersistedValue, Object defaultValue) { 17 | if (restorePersistedValue) { 18 | mTabsToShow = PrefStore.getInstance().getTabsToShow(); 19 | } else { 20 | final String string = (String) defaultValue; 21 | mTabsToShow = Tab.getTabsToShow(string); 22 | } 23 | } 24 | 25 | @Override 26 | protected View onCreateDialogView() { 27 | return new DragSortListView(getContext()); 28 | } 29 | 30 | @Override 31 | protected void onBindDialogView(@NonNull View view) { 32 | super.onBindDialogView(view); 33 | if (mTabsToShow == null) { 34 | onSetInitialValue(true, null); 35 | } 36 | 37 | final ArrayAdapter adapter = new StableArrayAdapter<>(getContext(), 38 | android.R.layout.simple_list_item_1, mTabsToShow); 39 | final DragSortListView listView = (DragSortListView) view; 40 | listView.setDataList(mTabsToShow); 41 | listView.setAdapter(adapter); 42 | } 43 | 44 | @Override 45 | protected void onDialogClosed(boolean positiveResult) { 46 | if (positiveResult) { 47 | persistString(Tab.getStringToSave(mTabsToShow)); 48 | } 49 | mTabsToShow = null; 50 | } 51 | 52 | } 53 | */ 54 | -------------------------------------------------------------------------------- /app/src/main/java/com/czbix/v2ex/ui/widget/ExSwipeRefreshLayout.java: -------------------------------------------------------------------------------- 1 | package com.czbix.v2ex.ui.widget; 2 | 3 | import android.content.Context; 4 | import androidx.swiperefreshlayout.widget.SwipeRefreshLayout; 5 | import android.util.AttributeSet; 6 | 7 | public class ExSwipeRefreshLayout extends SwipeRefreshLayout { 8 | private CanChildScrollUpCallback mCanChildScrollUpCallback; 9 | private boolean mPreMeasureRefreshing; 10 | private boolean mMeasured = false; 11 | 12 | public ExSwipeRefreshLayout(Context context) { 13 | this(context, null); 14 | } 15 | 16 | public ExSwipeRefreshLayout(Context context, AttributeSet attrs) { 17 | super(context, attrs); 18 | } 19 | 20 | public void setCanChildScrollUpCallback(CanChildScrollUpCallback canChildScrollUpCallback) { 21 | mCanChildScrollUpCallback = canChildScrollUpCallback; 22 | } 23 | 24 | public interface CanChildScrollUpCallback { 25 | boolean canSwipeRefreshChildScrollUp(); 26 | } 27 | 28 | @Override 29 | public boolean canChildScrollUp() { 30 | if (mCanChildScrollUpCallback != null) { 31 | return mCanChildScrollUpCallback.canSwipeRefreshChildScrollUp(); 32 | } 33 | return super.canChildScrollUp(); 34 | } 35 | 36 | @Override 37 | public void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { 38 | super.onMeasure(widthMeasureSpec, heightMeasureSpec); 39 | if (!mMeasured) { 40 | mMeasured = true; 41 | setRefreshing(mPreMeasureRefreshing); 42 | } 43 | } 44 | 45 | @Override 46 | public void setRefreshing(boolean refreshing) { 47 | /** 48 | * avoid refreshing icon not shown 49 | * https://code.google.com/p/android/issues/detail?id=77712 50 | */ 51 | if (mMeasured) { 52 | super.setRefreshing(refreshing); 53 | } else { 54 | mPreMeasureRefreshing = refreshing; 55 | } 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /app/src/main/java/com/czbix/v2ex/ui/widget/SearchListView.java: -------------------------------------------------------------------------------- 1 | package com.czbix.v2ex.ui.widget; 2 | 3 | import android.content.Context; 4 | import androidx.appcompat.widget.SearchView; 5 | import android.util.AttributeSet; 6 | import android.view.View; 7 | import android.widget.AdapterView; 8 | import android.widget.LinearLayout; 9 | import android.widget.ListView; 10 | 11 | import com.czbix.v2ex.R; 12 | 13 | /** 14 | * a listview with search feature 15 | */ 16 | public class SearchListView extends LinearLayout implements SearchView.OnQueryTextListener { 17 | private SearchView mSearchView; 18 | private ListView mListView; 19 | private ExArrayAdapter mAdapter; 20 | private View mLoading; 21 | 22 | public SearchListView(Context context) { 23 | this(context, null); 24 | } 25 | 26 | public SearchListView(Context context, AttributeSet attrs) { 27 | this(context, attrs, 0); 28 | } 29 | 30 | public SearchListView(Context context, AttributeSet attrs, int defStyle) { 31 | super(context, attrs, defStyle); 32 | 33 | setOrientation(VERTICAL); 34 | 35 | inflate(getContext(), R.layout.view_select_node, this); 36 | 37 | mSearchView = ((SearchView) findViewById(R.id.search)); 38 | mLoading = findViewById(R.id.loading); 39 | mListView = ((ListView) findViewById(R.id.select_dialog_listview)); 40 | 41 | mSearchView.setSubmitButtonEnabled(false); 42 | mSearchView.setOnQueryTextListener(this); 43 | mSearchView.setIconifiedByDefault(false); 44 | mListView.setVisibility(GONE); 45 | } 46 | 47 | public void setAdapter(ExArrayAdapter adapter) { 48 | if (mAdapter == null) { 49 | mLoading.setVisibility(GONE); 50 | mListView.setVisibility(VISIBLE); 51 | } 52 | mAdapter = adapter; 53 | mListView.setAdapter(adapter); 54 | 55 | mAdapter.getFilter().filter(mSearchView.getQuery()); 56 | } 57 | 58 | public void setOnItemClickListener(AdapterView.OnItemClickListener listener) { 59 | mListView.setOnItemClickListener(listener); 60 | } 61 | 62 | @Override 63 | public boolean onQueryTextSubmit(String query) { 64 | return false; 65 | } 66 | 67 | @Override 68 | public boolean onQueryTextChange(String newText) { 69 | if (mAdapter == null) { 70 | return true; 71 | } 72 | mAdapter.getFilter().filter(newText); 73 | return true; 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /app/src/main/java/com/czbix/v2ex/util/CrashlyticsUtils.java: -------------------------------------------------------------------------------- 1 | package com.czbix.v2ex.util; 2 | 3 | import com.google.firebase.crashlytics.FirebaseCrashlytics; 4 | 5 | public class CrashlyticsUtils { 6 | private static final String KEY_IS_LOGGED_IN = "is_logged_in"; 7 | 8 | public static void setUserState(boolean isLoggedIn) { 9 | FirebaseCrashlytics.getInstance().setCustomKey(KEY_IS_LOGGED_IN, isLoggedIn); 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /app/src/main/java/com/czbix/v2ex/util/TrackerUtils.kt: -------------------------------------------------------------------------------- 1 | package com.czbix.v2ex.util 2 | 3 | import android.content.Context 4 | import android.os.Bundle 5 | import com.google.firebase.analytics.FirebaseAnalytics 6 | import java.security.MessageDigest 7 | 8 | object TrackerUtils { 9 | private lateinit var analytics: FirebaseAnalytics 10 | 11 | private fun hashString(data: String): String { 12 | val salted = "cz-%s-bix".format(data) 13 | return MessageDigest.getInstance("MD5") 14 | .digest(salted.toByteArray()) 15 | .fold("") { str, it -> 16 | str + "%02x".format(it) 17 | } 18 | } 19 | 20 | fun init(context: Context) { 21 | analytics = FirebaseAnalytics.getInstance(context) 22 | } 23 | 24 | fun setUserId(id: String?) { 25 | val hashedId = if (id == null) null else hashString(id) 26 | analytics.setUserId(hashedId) 27 | } 28 | 29 | fun onTopicSwitchReply(isShow: Boolean) { 30 | val params = Bundle().apply { 31 | putBoolean("is_show", isShow) 32 | } 33 | 34 | analytics.logEvent(Event.SWITCH_REPLY, params) 35 | } 36 | 37 | fun onTopicReply() { 38 | analytics.logEvent(Event.REPLY_TOPIC, null) 39 | } 40 | 41 | fun onSearch() { 42 | analytics.logEvent(FirebaseAnalytics.Event.SEARCH, null) 43 | } 44 | 45 | fun onParseTopic(time: Long, commentCount: Int) { 46 | val params = Bundle().apply { 47 | putInt("cost_time", time.toInt()) 48 | putInt("topic_count", commentCount) 49 | } 50 | analytics.logEvent(Event.PARSE_TOPIC, params) 51 | } 52 | 53 | private object Event { 54 | const val SWITCH_REPLY = "switch_reply" 55 | const val REPLY_TOPIC = "reply_topic" 56 | const val PARSE_TOPIC = "parse_topic" 57 | } 58 | } -------------------------------------------------------------------------------- /app/src/main/java/com/czbix/v2ex/util/UserUtils.java: -------------------------------------------------------------------------------- 1 | package com.czbix.v2ex.util; 2 | 3 | import com.czbix.v2ex.common.UserState; 4 | import com.czbix.v2ex.common.exception.ConnectionException; 5 | import com.czbix.v2ex.common.exception.RemoteException; 6 | import com.czbix.v2ex.common.exception.RequestException; 7 | import com.czbix.v2ex.dao.ConfigDao; 8 | import com.czbix.v2ex.event.BaseEvent; 9 | import com.czbix.v2ex.helper.RxBus; 10 | import com.czbix.v2ex.model.Avatar; 11 | import com.czbix.v2ex.network.RequestHelper; 12 | import com.google.common.base.Preconditions; 13 | 14 | public class UserUtils { 15 | private static final String TAG = UserUtils.class.getSimpleName(); 16 | 17 | public static Avatar getAvatar() { 18 | Preconditions.checkState(UserState.INSTANCE.isLoggedIn()); 19 | 20 | final String url = ConfigDao.get(ConfigDao.KEY_AVATAR, null); 21 | Preconditions.checkNotNull(url); 22 | 23 | return new Avatar.Builder().setBaseUrl(url).build(); 24 | } 25 | 26 | public static void checkDailyAward() { 27 | if (!UserState.INSTANCE.isLoggedIn()) { 28 | return; 29 | } 30 | 31 | boolean hasAward; 32 | try { 33 | hasAward = RequestHelper.INSTANCE.hasDailyAward(); 34 | } catch (ConnectionException | RemoteException | RequestException e) { 35 | LogUtils.v(TAG, "check daily award failed", e); 36 | return; 37 | } 38 | 39 | if (hasAward) { 40 | RxBus.INSTANCE.post(new BaseEvent.DailyAwardEvent(true)); 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /app/src/main/kotlin/com/czbix/v2ex/DebugHelpers.kt: -------------------------------------------------------------------------------- 1 | package com.czbix.v2ex 2 | 3 | import okhttp3.OkHttpClient 4 | 5 | open class DebugHelpers { 6 | open fun addStethoInterceptor(builder: OkHttpClient.Builder): OkHttpClient.Builder { 7 | return builder 8 | } 9 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/com/czbix/v2ex/common/UpdateInfo.kt: -------------------------------------------------------------------------------- 1 | package com.czbix.v2ex.common 2 | 3 | import com.czbix.v2ex.BuildConfig 4 | import com.czbix.v2ex.event.AppUpdateEvent 5 | import com.czbix.v2ex.helper.RxBus 6 | import com.czbix.v2ex.util.LogUtils 7 | import com.czbix.v2ex.util.getLogTag 8 | 9 | object UpdateInfo { 10 | private val TAG = getLogTag() 11 | 12 | var hasNewVersion: Boolean = false 13 | private set 14 | var isRecommend: Boolean = false 15 | private set 16 | 17 | fun parseVersionData(info: VersionInfo) { 18 | val currentVersion = BuildConfig.VERSION_CODE 19 | 20 | if (info.version <= currentVersion) { 21 | // no new version 22 | return 23 | } 24 | 25 | hasNewVersion = true 26 | isRecommend = info.recommend 27 | 28 | LogUtils.i(TAG, "new app version: $info") 29 | RxBus.post(AppUpdateEvent()) 30 | } 31 | 32 | data class VersionInfo( 33 | val version: Int, 34 | val recommend: Boolean 35 | ) 36 | } 37 | -------------------------------------------------------------------------------- /app/src/main/kotlin/com/czbix/v2ex/common/exception/ConnectionException.kt: -------------------------------------------------------------------------------- 1 | package com.czbix.v2ex.common.exception 2 | 3 | import java.io.IOException 4 | 5 | class ConnectionException : IOException { 6 | constructor() { 7 | } 8 | 9 | constructor(detailMessage: String) : super(detailMessage) { 10 | } 11 | 12 | constructor(detailMessage: String, throwable: Throwable) : super(detailMessage, throwable) { 13 | } 14 | 15 | constructor(throwable: Throwable) : super(throwable) { 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /app/src/main/kotlin/com/czbix/v2ex/common/exception/RestrictedException.kt: -------------------------------------------------------------------------------- 1 | package com.czbix.v2ex.common.exception 2 | 3 | import okhttp3.Response 4 | 5 | class RestrictedException(response: Response, tr: Throwable? = null) : UnauthorizedException(response, tr) 6 | -------------------------------------------------------------------------------- /app/src/main/kotlin/com/czbix/v2ex/common/exception/TwoFactorAuthException.kt: -------------------------------------------------------------------------------- 1 | package com.czbix.v2ex.common.exception 2 | 3 | import okhttp3.Response 4 | 5 | class TwoFactorAuthException(response: Response, tr: Throwable? = null) : UnauthorizedException(response, tr) 6 | -------------------------------------------------------------------------------- /app/src/main/kotlin/com/czbix/v2ex/common/exception/UrlRedirectException.kt: -------------------------------------------------------------------------------- 1 | package com.czbix.v2ex.common.exception 2 | 3 | import okhttp3.Response 4 | 5 | class UrlRedirectException(val url: String, message: String, response: Response, tr: Throwable? = null) 6 | : RequestException(message, response, tr) 7 | -------------------------------------------------------------------------------- /app/src/main/kotlin/com/czbix/v2ex/db/Comment.kt: -------------------------------------------------------------------------------- 1 | package com.czbix.v2ex.db 2 | 3 | import androidx.room.ColumnInfo 4 | import androidx.room.Entity 5 | import androidx.room.PrimaryKey 6 | import com.czbix.v2ex.model.Ignorable 7 | import com.czbix.v2ex.model.Thankable 8 | import com.czbix.v2ex.network.RequestHelper 9 | 10 | @Entity 11 | data class Comment( 12 | @PrimaryKey 13 | val id: Int, 14 | @ColumnInfo(index = true) 15 | val topicId: Int, 16 | val page: Int, 17 | val content: String, 18 | val username: String, 19 | val addAt: String, 20 | val thanks: Int, 21 | val floor: Int, 22 | val thanked: Boolean 23 | ) : Thankable, Ignorable { 24 | 25 | override fun getIgnoreUrl(): String { 26 | return String.format("%s/ignore/reply/%d", RequestHelper.BASE_URL, id) 27 | } 28 | 29 | override fun getThankUrl(): String { 30 | return String.format("%s/thank/reply/%d", RequestHelper.BASE_URL, id) 31 | } 32 | 33 | override fun equals(other: Any?): Boolean { 34 | if (this === other) return true 35 | if (javaClass != other?.javaClass) return false 36 | 37 | other as Comment 38 | 39 | if (id != other.id) return false 40 | if (topicId != other.topicId) return false 41 | if (page != other.page) return false 42 | if (content != other.content) return false 43 | if (username != other.username) return false 44 | if (addAt != other.addAt) return false 45 | if (thanks != other.thanks) return false 46 | if (floor != other.floor) return false 47 | if (thanked != other.thanked) return false 48 | 49 | return true 50 | } 51 | 52 | override fun hashCode(): Int { 53 | var result = id 54 | result = 31 * result + topicId 55 | result = 31 * result + page 56 | result = 31 * result + content.hashCode() 57 | result = 31 * result + username.hashCode() 58 | result = 31 * result + addAt.hashCode() 59 | result = 31 * result + thanks 60 | result = 31 * result + floor 61 | result = 31 * result + thanked.hashCode() 62 | return result 63 | } 64 | 65 | class Builder { 66 | var topicId: Int = 0 67 | var id: Int = 0 68 | var page: Int = 0 69 | lateinit var content: String 70 | lateinit var username: String 71 | lateinit var addAt: String 72 | var thanks: Int = 0 73 | var floor: Int = 0 74 | var thanked: Boolean = false 75 | 76 | fun build(): Comment { 77 | check(topicId > 0) 78 | check(id > 0) 79 | check(floor > 0) 80 | check(page > 0) 81 | 82 | return Comment(id, topicId, page, content, username, addAt, thanks, floor, thanked) 83 | } 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /app/src/main/kotlin/com/czbix/v2ex/db/CommentAndMember.kt: -------------------------------------------------------------------------------- 1 | package com.czbix.v2ex.db 2 | 3 | import androidx.room.Embedded 4 | 5 | data class CommentAndMember( 6 | @Embedded 7 | val comment: Comment, 8 | 9 | @Embedded(prefix = "member_") 10 | val member: Member 11 | ) 12 | 13 | -------------------------------------------------------------------------------- /app/src/main/kotlin/com/czbix/v2ex/db/CommentDao.kt: -------------------------------------------------------------------------------- 1 | package com.czbix.v2ex.db 2 | 3 | import androidx.paging.DataSource 4 | import androidx.room.* 5 | 6 | @Dao 7 | abstract class CommentDao { 8 | @Query("SELECT Comment.*, Member.username as member_username, Member.baseUrl as member_baseUrl FROM Comment INNER JOIN Member ON Comment.username = Member.username WHERE topicId = :id ORDER BY id ASC") 9 | abstract fun getCommentsByTopicId(id: Int): DataSource.Factory 10 | 11 | @Delete(entity = Comment::class) 12 | abstract suspend fun deleteCommentsByPage(page: CommentPage) 13 | 14 | @Insert(onConflict = OnConflictStrategy.REPLACE) 15 | abstract suspend fun insertComments(comments: List) 16 | 17 | @Insert(onConflict = OnConflictStrategy.REPLACE) 18 | abstract suspend fun insertMembers(members: List) 19 | 20 | @Transaction 21 | open suspend fun updateCommentAndMembers(list: List, page: CommentPage) { 22 | val comments = MutableList(list.size) { 23 | list[it].comment 24 | } 25 | val members = list.map { 26 | it.member 27 | }.toSet().toList() 28 | 29 | deleteCommentsByPage(page) 30 | insertMembers(members) 31 | insertComments(comments) 32 | } 33 | 34 | class CommentPage( 35 | val topicId: Int, 36 | val page: Int 37 | ) 38 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/com/czbix/v2ex/db/TopicRecord.kt: -------------------------------------------------------------------------------- 1 | package com.czbix.v2ex.db 2 | 3 | import androidx.room.Entity 4 | import androidx.room.PrimaryKey 5 | 6 | @Entity 7 | data class TopicRecord( 8 | @PrimaryKey 9 | val id: Int, 10 | val title: String, 11 | var lastReadAt: Long, 12 | var lastReadComment: Int 13 | ) 14 | 15 | -------------------------------------------------------------------------------- /app/src/main/kotlin/com/czbix/v2ex/db/TopicRecordDao.kt: -------------------------------------------------------------------------------- 1 | package com.czbix.v2ex.db 2 | 3 | import androidx.room.Dao 4 | import androidx.room.Insert 5 | import androidx.room.OnConflictStrategy 6 | import androidx.room.Query 7 | 8 | 9 | @Dao 10 | interface TopicRecordDao { 11 | @Query("SELECT * FROM TopicRecord WHERE id = :id") 12 | suspend fun getRecordById(id: Int): TopicRecord? 13 | 14 | @Query("SELECT lastReadComment FROM TopicRecord WHERE id = :id") 15 | fun getLastReadComment(id: Int): Int? 16 | 17 | @Insert(onConflict = OnConflictStrategy.REPLACE) 18 | fun updateRecord(record: TopicRecord) 19 | } 20 | -------------------------------------------------------------------------------- /app/src/main/kotlin/com/czbix/v2ex/db/V2Db.kt: -------------------------------------------------------------------------------- 1 | package com.czbix.v2ex.db 2 | 3 | import android.content.Context 4 | import androidx.room.Database 5 | import androidx.room.Room 6 | import androidx.room.RoomDatabase 7 | 8 | @Database( 9 | entities = [ 10 | TopicRecord::class, 11 | Comment::class, 12 | Member::class 13 | ], 14 | version = 1 15 | ) 16 | abstract class V2Db : RoomDatabase() { 17 | companion object { 18 | fun create(context: Context): V2Db { 19 | val builder = Room.databaseBuilder(context, V2Db::class.java, "v2db.db") 20 | return builder.build() 21 | } 22 | } 23 | 24 | abstract fun topicRecords(): TopicRecordDao 25 | 26 | abstract fun comments(): CommentDao 27 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/com/czbix/v2ex/event/AppUpdateEvent.kt: -------------------------------------------------------------------------------- 1 | package com.czbix.v2ex.event 2 | 3 | class AppUpdateEvent : BaseEvent() -------------------------------------------------------------------------------- /app/src/main/kotlin/com/czbix/v2ex/event/BaseEvent.kt: -------------------------------------------------------------------------------- 1 | package com.czbix.v2ex.event 2 | 3 | abstract class BaseEvent { 4 | class NewUnreadEvent(val mCount: Int) : BaseEvent() { 5 | fun hasNew(): Boolean { 6 | return mCount > 0 7 | } 8 | } 9 | 10 | class DailyAwardEvent(val mHasAward: Boolean) : BaseEvent() 11 | 12 | class ContextInitFinishEvent : BaseEvent() 13 | 14 | class NodeEvent : BaseEvent() 15 | } 16 | -------------------------------------------------------------------------------- /app/src/main/kotlin/com/czbix/v2ex/event/DeviceRegisterEvent.java: -------------------------------------------------------------------------------- 1 | package com.czbix.v2ex.event; 2 | 3 | 4 | public class DeviceRegisterEvent extends BaseEvent { 5 | public final boolean isRegister; 6 | public final boolean isSuccess; 7 | 8 | public DeviceRegisterEvent(boolean isRegister, boolean isSuccess) { 9 | this.isRegister = isRegister; 10 | this.isSuccess = isSuccess; 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /app/src/main/kotlin/com/czbix/v2ex/helper/RxBus.kt: -------------------------------------------------------------------------------- 1 | package com.czbix.v2ex.helper 2 | 3 | import com.czbix.v2ex.event.BaseEvent 4 | import io.reactivex.Observable 5 | import io.reactivex.Scheduler 6 | import io.reactivex.android.schedulers.AndroidSchedulers 7 | import io.reactivex.disposables.Disposable 8 | import io.reactivex.rxkotlin.cast 9 | import io.reactivex.subjects.PublishSubject 10 | import io.reactivex.subjects.Subject 11 | 12 | object RxBus { 13 | private val subject: Subject 14 | 15 | init { 16 | subject = PublishSubject.create().toSerialized() 17 | } 18 | 19 | fun post(event: BaseEvent) { 20 | subject.onNext(event) 21 | } 22 | 23 | @JvmName("toObservableAny") 24 | fun toObservable(): Observable { 25 | return subject 26 | } 27 | 28 | inline fun toObservable(): Observable { 29 | return toObservable().filter { it is T }.cast() 30 | } 31 | 32 | @JvmName("subscribeAny") 33 | fun subscribe(scheduler: Scheduler = AndroidSchedulers.mainThread(), 34 | action: (BaseEvent) -> Unit): Disposable { 35 | return toObservable() 36 | .observeOn(scheduler) 37 | .subscribe(action) 38 | } 39 | 40 | inline fun subscribe(scheduler: Scheduler = AndroidSchedulers.mainThread(), 41 | noinline action: (T) -> Unit): Disposable { 42 | return toObservable() 43 | .observeOn(scheduler) 44 | .subscribe(action) 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /app/src/main/kotlin/com/czbix/v2ex/inject/ActivityModule.kt: -------------------------------------------------------------------------------- 1 | package com.czbix.v2ex.inject 2 | 3 | import com.czbix.v2ex.ui.LoginActivity 4 | import com.czbix.v2ex.ui.MainActivity 5 | import com.czbix.v2ex.ui.SettingsActivity 6 | import com.czbix.v2ex.ui.TopicActivity 7 | import com.czbix.v2ex.ui.settings.SettingsModule 8 | import dagger.Module 9 | import dagger.android.ContributesAndroidInjector 10 | 11 | 12 | @Module 13 | abstract class ActivityModule { 14 | @ActivityScoped 15 | @ContributesAndroidInjector(modules = [MainFragmentModule::class]) 16 | abstract fun mainActivity(): MainActivity 17 | 18 | @ActivityScoped 19 | @ContributesAndroidInjector(modules = [TopicFragmentModule::class]) 20 | abstract fun topicActivity(): TopicActivity 21 | 22 | @ActivityScoped 23 | @ContributesAndroidInjector(modules = [SettingsModule::class]) 24 | abstract fun settingsActivity(): SettingsActivity 25 | 26 | @ActivityScoped 27 | @ContributesAndroidInjector 28 | abstract fun loginActivity(): LoginActivity 29 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/com/czbix/v2ex/inject/AppComponent.kt: -------------------------------------------------------------------------------- 1 | package com.czbix.v2ex.inject 2 | 3 | import com.czbix.v2ex.AppCtx 4 | import dagger.BindsInstance 5 | import dagger.Component 6 | import dagger.android.AndroidInjectionModule 7 | import dagger.android.AndroidInjector 8 | import javax.inject.Singleton 9 | 10 | @Singleton 11 | @Component( 12 | modules = [ 13 | AndroidInjectionModule::class, 14 | AppModule::class, 15 | DbModule::class, 16 | ViewModelModule::class, 17 | ActivityModule::class, 18 | NightModeModule::class 19 | ] 20 | ) 21 | interface AppComponent : AndroidInjector { 22 | @Component.Builder 23 | interface Builder { 24 | @BindsInstance 25 | fun application(application: AppCtx): Builder 26 | 27 | fun build(): AppComponent 28 | } 29 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/com/czbix/v2ex/inject/AppInjector.kt: -------------------------------------------------------------------------------- 1 | package com.czbix.v2ex.inject 2 | 3 | import android.app.Activity 4 | import android.app.Application 5 | import android.content.Context 6 | import android.os.Bundle 7 | import androidx.fragment.app.Fragment 8 | import androidx.fragment.app.FragmentActivity 9 | import androidx.fragment.app.FragmentManager 10 | import com.czbix.v2ex.AppCtx 11 | import dagger.android.support.AndroidSupportInjection 12 | 13 | object AppInjector { 14 | fun init(app: AppCtx) { 15 | DaggerAppComponent.builder().application(app) 16 | .build().inject(app) 17 | app.registerActivityLifecycleCallbacks(object : Application.ActivityLifecycleCallbacks { 18 | override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) { 19 | handleActivity(activity) 20 | } 21 | 22 | override fun onActivityStarted(activity: Activity) { 23 | 24 | } 25 | 26 | override fun onActivityResumed(activity: Activity) { 27 | 28 | } 29 | 30 | override fun onActivityPaused(activity: Activity) { 31 | 32 | } 33 | 34 | override fun onActivityStopped(activity: Activity) { 35 | 36 | } 37 | 38 | override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) { 39 | 40 | } 41 | 42 | override fun onActivityDestroyed(activity: Activity) { 43 | 44 | } 45 | }) 46 | } 47 | 48 | private fun handleActivity(activity: Activity) { 49 | if (activity is FragmentActivity) { 50 | activity.supportFragmentManager 51 | .registerFragmentLifecycleCallbacks( 52 | object : FragmentManager.FragmentLifecycleCallbacks() { 53 | override fun onFragmentAttached(fm: FragmentManager, f: Fragment, context: Context) { 54 | if (f is Injectable) { 55 | AndroidSupportInjection.inject(f) 56 | } 57 | } 58 | }, true 59 | ) 60 | } 61 | } 62 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/com/czbix/v2ex/inject/AppModule.kt: -------------------------------------------------------------------------------- 1 | package com.czbix.v2ex.inject 2 | 3 | import android.content.ClipboardManager 4 | import android.content.Context 5 | import android.net.ConnectivityManager 6 | import android.net.wifi.WifiManager 7 | import com.czbix.v2ex.AppCtx 8 | import com.czbix.v2ex.common.PrefStore 9 | import com.czbix.v2ex.model.PreferenceStorage 10 | import com.czbix.v2ex.network.RequestHelper 11 | import com.czbix.v2ex.network.V2exService 12 | import dagger.Module 13 | import dagger.Provides 14 | import javax.inject.Singleton 15 | 16 | @Module 17 | class AppModule { 18 | @Singleton 19 | @Provides 20 | fun provideContext(app: AppCtx): Context { 21 | return app.applicationContext 22 | } 23 | 24 | @Provides 25 | fun providesWifiManager(context: Context): WifiManager { 26 | return context.applicationContext.getSystemService(Context.WIFI_SERVICE) as WifiManager 27 | } 28 | 29 | @Provides 30 | fun providesConnectivityManager(context: Context): ConnectivityManager { 31 | return context.applicationContext.getSystemService(Context.CONNECTIVITY_SERVICE) 32 | as ConnectivityManager 33 | } 34 | 35 | @Provides 36 | fun providesClipboardManager(context: Context): ClipboardManager { 37 | return context.applicationContext.getSystemService(Context.CLIPBOARD_SERVICE) 38 | as ClipboardManager 39 | } 40 | 41 | @Singleton 42 | @Provides 43 | fun providesPrefStore(): PrefStore { 44 | return PrefStore.getInstance() 45 | } 46 | 47 | @Singleton 48 | @Provides 49 | fun providesPreferenceStorage(context: Context): PreferenceStorage { 50 | return PreferenceStorage.SharedPreferenceStorage(context) 51 | } 52 | 53 | @Singleton 54 | @Provides 55 | fun provideV2exService(): V2exService { 56 | return V2exService(RequestHelper) 57 | } 58 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/com/czbix/v2ex/inject/DbModule.kt: -------------------------------------------------------------------------------- 1 | package com.czbix.v2ex.inject 2 | 3 | import android.content.Context 4 | import com.czbix.v2ex.db.CommentDao 5 | import com.czbix.v2ex.db.TopicRecordDao 6 | import com.czbix.v2ex.db.V2Db 7 | import dagger.Module 8 | import dagger.Provides 9 | import javax.inject.Singleton 10 | 11 | 12 | @Module 13 | class DbModule { 14 | @Singleton 15 | @Provides 16 | fun provideDb(context: Context): V2Db { 17 | return V2Db.create(context) 18 | } 19 | 20 | @Provides 21 | fun provideTopicRecordDao(db: V2Db): TopicRecordDao { 22 | return db.topicRecords() 23 | } 24 | 25 | @Provides 26 | fun provideComments(db: V2Db): CommentDao { 27 | return db.comments() 28 | } 29 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/com/czbix/v2ex/inject/Injectable.kt: -------------------------------------------------------------------------------- 1 | package com.czbix.v2ex.inject 2 | 3 | interface Injectable -------------------------------------------------------------------------------- /app/src/main/kotlin/com/czbix/v2ex/inject/MainFragmentModule.kt: -------------------------------------------------------------------------------- 1 | package com.czbix.v2ex.inject 2 | 3 | import com.czbix.v2ex.ui.fragment.TopicListFragment 4 | import dagger.Module 5 | import dagger.android.ContributesAndroidInjector 6 | 7 | @Module 8 | abstract class MainFragmentModule { 9 | @ContributesAndroidInjector 10 | abstract fun topicListFragment(): TopicListFragment 11 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/com/czbix/v2ex/inject/NightModeModule.kt: -------------------------------------------------------------------------------- 1 | package com.czbix.v2ex.inject 2 | 3 | import androidx.lifecycle.ViewModel 4 | import com.czbix.v2ex.ui.model.NightModeDelegate 5 | import com.czbix.v2ex.ui.model.NightModeDelegateImpl 6 | import com.czbix.v2ex.ui.model.NightModeViewModel 7 | import dagger.Binds 8 | import dagger.Module 9 | import dagger.multibindings.IntoMap 10 | import javax.inject.Singleton 11 | 12 | @Module 13 | abstract class NightModeModule { 14 | @Singleton 15 | @Binds 16 | abstract fun provideNightModeDelegate(impl: NightModeDelegateImpl): NightModeDelegate 17 | 18 | @Binds 19 | @IntoMap 20 | @ViewModelKey(NightModeViewModel::class) 21 | abstract fun provideNightModeViewModel(viewModel: NightModeViewModel): ViewModel 22 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/com/czbix/v2ex/inject/Scoped.kt: -------------------------------------------------------------------------------- 1 | package com.czbix.v2ex.inject 2 | 3 | import javax.inject.Scope 4 | 5 | @Scope 6 | @Retention(AnnotationRetention.RUNTIME) 7 | annotation class ActivityScoped 8 | 9 | @Scope 10 | @Retention(AnnotationRetention.RUNTIME) 11 | @Target(AnnotationTarget.TYPE, AnnotationTarget.CLASS, AnnotationTarget.FUNCTION) 12 | annotation class FragmentScoped -------------------------------------------------------------------------------- /app/src/main/kotlin/com/czbix/v2ex/inject/TopicFragmentModule.kt: -------------------------------------------------------------------------------- 1 | package com.czbix.v2ex.inject 2 | 3 | import androidx.lifecycle.ViewModel 4 | import com.czbix.v2ex.ui.fragment.TopicFragment 5 | import com.czbix.v2ex.ui.model.TopicViewModel 6 | import dagger.Binds 7 | import dagger.Module 8 | import dagger.android.ContributesAndroidInjector 9 | import dagger.multibindings.IntoMap 10 | 11 | @Module 12 | abstract class TopicFragmentModule { 13 | @Binds 14 | @IntoMap 15 | @ViewModelKey(TopicViewModel::class) 16 | abstract fun bindTopicViewModel(viewModel: TopicViewModel): ViewModel 17 | 18 | @FragmentScoped 19 | @ContributesAndroidInjector 20 | abstract fun topicFragment(): TopicFragment 21 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/com/czbix/v2ex/inject/ViewModelKey.kt: -------------------------------------------------------------------------------- 1 | package com.czbix.v2ex.inject 2 | 3 | import androidx.lifecycle.ViewModel 4 | import dagger.MapKey 5 | import kotlin.reflect.KClass 6 | 7 | @Target( 8 | AnnotationTarget.FUNCTION, 9 | AnnotationTarget.PROPERTY_GETTER, 10 | AnnotationTarget.PROPERTY_SETTER 11 | ) 12 | @Retention(AnnotationRetention.RUNTIME) 13 | @MapKey 14 | annotation class ViewModelKey(val value: KClass) -------------------------------------------------------------------------------- /app/src/main/kotlin/com/czbix/v2ex/inject/ViewModelModule.kt: -------------------------------------------------------------------------------- 1 | package com.czbix.v2ex.inject 2 | 3 | 4 | import androidx.lifecycle.ViewModelProvider 5 | import com.czbix.v2ex.model.ViewModelFactory 6 | import dagger.Binds 7 | import dagger.Module 8 | 9 | @Module 10 | abstract class ViewModelModule { 11 | @Binds 12 | abstract fun bindViewModelFactory(factory: ViewModelFactory): ViewModelProvider.Factory 13 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/com/czbix/v2ex/model/AbsentLiveData.kt: -------------------------------------------------------------------------------- 1 | package com.czbix.v2ex.model 2 | 3 | import androidx.lifecycle.LiveData 4 | 5 | /** 6 | * A LiveData class that has `null` value. 7 | */ 8 | class AbsentLiveData( 9 | data: T? = null 10 | ): LiveData() { 11 | init { 12 | // use post instead of set since this can be created on any thread 13 | postValue(data) 14 | } 15 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/com/czbix/v2ex/model/ContentBlock.kt: -------------------------------------------------------------------------------- 1 | package com.czbix.v2ex.model 2 | 3 | import android.os.Parcelable 4 | import kotlinx.android.parcel.Parcelize 5 | 6 | sealed class ContentBlock : Parcelable { 7 | @Parcelize 8 | data class TextBlock(val id: Int, val text: CharSequence) : ContentBlock() 9 | 10 | @Parcelize 11 | data class ImageBlock(val id: Int, val source: String) : ContentBlock() 12 | 13 | @Parcelize 14 | data class PreBlock(val id: Int, val text: CharSequence) : ContentBlock() 15 | } 16 | -------------------------------------------------------------------------------- /app/src/main/kotlin/com/czbix/v2ex/model/EmptyLiveData.kt: -------------------------------------------------------------------------------- 1 | package com.czbix.v2ex.model 2 | 3 | import androidx.lifecycle.LiveData 4 | 5 | class EmptyLiveData : LiveData() { 6 | companion object { 7 | fun create(): EmptyLiveData { 8 | return EmptyLiveData() 9 | } 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /app/src/main/kotlin/com/czbix/v2ex/model/Postscript.kt: -------------------------------------------------------------------------------- 1 | package com.czbix.v2ex.model 2 | 3 | import android.os.Parcelable 4 | import kotlinx.android.parcel.Parcelize 5 | 6 | @Parcelize 7 | data class Postscript( 8 | val content: List, 9 | val time: String 10 | ) : Parcelable 11 | -------------------------------------------------------------------------------- /app/src/main/kotlin/com/czbix/v2ex/model/Resource.kt: -------------------------------------------------------------------------------- 1 | package com.czbix.v2ex.model 2 | 3 | sealed class Resource( 4 | val status: Status, 5 | open val data: T?, 6 | open val exception: Exception? 7 | ) { 8 | enum class Status { 9 | LOADING, 10 | SUCCESS, 11 | FAILED, 12 | } 13 | 14 | fun map(block: (T) -> R): Resource { 15 | return when (this) { 16 | is Loading -> Loading(data?.let(block)) 17 | is Success -> Success(data.let(block)) 18 | is Failed -> Failed(exception, data?.let(block)) 19 | } 20 | } 21 | 22 | data class Loading( 23 | override val data: T? = null 24 | ) : Resource(Status.LOADING, data, null) 25 | 26 | data class Success( 27 | override val data: T 28 | ) : Resource(Status.SUCCESS, data, null) 29 | 30 | data class Failed( 31 | override val exception: Exception, 32 | override val data: T? = null 33 | ) : Resource(Status.FAILED, data, exception) 34 | 35 | override fun toString(): String { 36 | return when (this) { 37 | is Success -> "Success[data=$data]" 38 | is Failed -> "Failed[exception=$exception]" 39 | is Loading -> "Loading" 40 | } 41 | } 42 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/com/czbix/v2ex/model/ServerConfig.kt: -------------------------------------------------------------------------------- 1 | package com.czbix.v2ex.model 2 | 3 | import com.czbix.v2ex.common.UpdateInfo 4 | 5 | data class ServerConfig( 6 | val version: UpdateInfo.VersionInfo 7 | ) 8 | -------------------------------------------------------------------------------- /app/src/main/kotlin/com/czbix/v2ex/model/TopicResponse.kt: -------------------------------------------------------------------------------- 1 | package com.czbix.v2ex.model 2 | 3 | import com.czbix.v2ex.db.CommentAndMember 4 | 5 | class TopicResponse( 6 | val topic: Topic, 7 | val comments: List, 8 | val curPage: Int, 9 | val maxPage: Int, 10 | val csrfToken: String?, 11 | val onceToken: String? 12 | ) { 13 | val nextPage = if (curPage < maxPage) curPage + 1 else 0 14 | } 15 | -------------------------------------------------------------------------------- /app/src/main/kotlin/com/czbix/v2ex/model/ViewModelFactory.kt: -------------------------------------------------------------------------------- 1 | package com.czbix.v2ex.model 2 | 3 | import androidx.lifecycle.ViewModel 4 | import androidx.lifecycle.ViewModelProvider 5 | import javax.inject.Inject 6 | import javax.inject.Provider 7 | 8 | class ViewModelFactory @Inject constructor( 9 | private val creators: @JvmSuppressWildcards Map, Provider> 10 | ) : ViewModelProvider.Factory { 11 | override fun create(modelClass: Class): T { 12 | val found = creators[modelClass] ?: creators.entries.find { 13 | modelClass.isAssignableFrom(it.key) 14 | }?.value 15 | 16 | requireNotNull(found) { 17 | "Unknown model class $modelClass" 18 | } 19 | 20 | try { 21 | @Suppress("UNCHECKED_CAST") 22 | return found.get() as T 23 | } catch (e: Exception) { 24 | throw RuntimeException(e) 25 | } 26 | } 27 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/com/czbix/v2ex/model/loader/GooglePhotoUrlLoader.kt: -------------------------------------------------------------------------------- 1 | package com.czbix.v2ex.model.loader 2 | 3 | import android.annotation.SuppressLint 4 | import com.bumptech.glide.load.Options 5 | import com.bumptech.glide.load.model.GlideUrl 6 | import com.bumptech.glide.load.model.ModelLoader 7 | import com.bumptech.glide.load.model.ModelLoaderFactory 8 | import com.bumptech.glide.load.model.MultiModelLoaderFactory 9 | import com.bumptech.glide.load.model.stream.BaseGlideUrlLoader 10 | import java.io.InputStream 11 | import kotlin.random.Random 12 | 13 | class GooglePhotoUrlLoader constructor(concreteLoader: ModelLoader) : BaseGlideUrlLoader(concreteLoader) { 14 | @SuppressLint("DefaultLocale") 15 | override fun getUrl(s: String, width: Int, height: Int, options: Options): String { 16 | return String.format("https://lh%d.%s=w%d-h%d", Random.nextInt(3,7), s, width, height) 17 | } 18 | 19 | override fun handles(s: String): Boolean { 20 | return s.startsWith("ggpht.com/") 21 | } 22 | 23 | class Factory : ModelLoaderFactory { 24 | override fun build(multiFactory: MultiModelLoaderFactory): ModelLoader { 25 | return GooglePhotoUrlLoader(multiFactory.build(GlideUrl::class.java, InputStream::class.java)) 26 | } 27 | 28 | override fun teardown() { 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /app/src/main/kotlin/com/czbix/v2ex/network/NetworkState.kt: -------------------------------------------------------------------------------- 1 | package com.czbix.v2ex.network 2 | 3 | enum class NetworkState { 4 | RUNNING, 5 | SUCCESS, 6 | FAILED, 7 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/com/czbix/v2ex/network/repository/BaseRepository.kt: -------------------------------------------------------------------------------- 1 | package com.czbix.v2ex.network.repository 2 | 3 | import androidx.lifecycle.LiveData 4 | import com.czbix.v2ex.model.NetworkBoundResource 5 | import com.czbix.v2ex.model.Resource 6 | import com.czbix.v2ex.util.liveData 7 | 8 | open class BaseRepository { 9 | protected fun wrapCall(call: suspend () -> T): LiveData> { 10 | return liveData { 11 | try { 12 | val result = call() 13 | Resource.Success(result) 14 | } catch (e: Exception) { 15 | Resource.Failed(e) 16 | } 17 | } 18 | } 19 | 20 | protected inline fun networkOnlyCall(crossinline call: suspend () -> T): LiveData> { 21 | return NetworkBoundResource.NetworkOnlyResource { 22 | wrapCall { 23 | call() 24 | } 25 | }.asLiveData() 26 | } 27 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/com/czbix/v2ex/network/repository/TopicRepository.kt: -------------------------------------------------------------------------------- 1 | package com.czbix.v2ex.network.repository 2 | 3 | import androidx.lifecycle.LiveData 4 | import androidx.lifecycle.MutableLiveData 5 | import androidx.paging.PagedList 6 | import androidx.paging.toLiveData 7 | import com.czbix.v2ex.db.CommentAndMember 8 | import com.czbix.v2ex.db.CommentDao 9 | import com.czbix.v2ex.model.* 10 | import com.czbix.v2ex.network.V2exService 11 | import kotlinx.coroutines.runBlocking 12 | import javax.inject.Inject 13 | import javax.inject.Singleton 14 | 15 | @Singleton 16 | class TopicRepository @Inject constructor( 17 | private val service: V2exService, 18 | private val commentDao: CommentDao 19 | ) : BaseRepository() { 20 | 21 | fun loadTopic(topic: Topic, page: Int): LiveData> { 22 | return object : NetworkBoundResource() { 23 | private val liveData = MutableLiveData(null) 24 | 25 | override fun saveCallResult(item: TopicResponse) { 26 | runBlocking { 27 | commentDao.updateCommentAndMembers(item.comments, CommentDao.CommentPage(topic.id, page)) 28 | } 29 | liveData.postValue(item) 30 | } 31 | 32 | override fun shouldFetch(data: TopicResponse?): Boolean { 33 | return true 34 | } 35 | 36 | override fun loadFromDb(): LiveData { 37 | return liveData 38 | } 39 | 40 | override fun createCall(): LiveData> { 41 | return wrapCall { 42 | service.getTopic(topic, page) 43 | } 44 | } 45 | }.asLiveData() 46 | } 47 | 48 | fun loadLocalTopicComments(topicId: Int): LiveData> { 49 | return commentDao.getCommentsByTopicId(topicId).toLiveData(pageSize = 100) 50 | } 51 | 52 | fun postComment(topic: Topic, content: String, onceToken: String) = networkOnlyCall { 53 | service.postComment(topic, content, onceToken) 54 | } 55 | 56 | fun favTopic(topic: Topic, bool: Boolean, csrfToken: String) = networkOnlyCall { 57 | service.favor(topic, bool, csrfToken) 58 | } 59 | 60 | fun thank(thankable: Thankable, onceToken: String) = networkOnlyCall { 61 | service.thank(thankable, onceToken) 62 | } 63 | 64 | fun ignore(ignorable: Ignorable, onceToken: String) = networkOnlyCall { 65 | service.ignore(ignorable, onceToken) 66 | } 67 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/com/czbix/v2ex/ui/AutoClearedValue.kt: -------------------------------------------------------------------------------- 1 | package com.czbix.v2ex.ui 2 | 3 | import androidx.fragment.app.Fragment 4 | import androidx.lifecycle.Lifecycle 5 | import androidx.lifecycle.LifecycleObserver 6 | import androidx.lifecycle.OnLifecycleEvent 7 | import kotlin.properties.ReadWriteProperty 8 | import kotlin.reflect.KProperty 9 | 10 | /** 11 | * A lazy property that gets cleaned up when the fragment is destroyed. 12 | * 13 | * Accessing this variable in a destroyed fragment will throw NPE. 14 | */ 15 | class AutoClearedValue(val fragment: Fragment) : ReadWriteProperty { 16 | private var _value: T? = null 17 | 18 | init { 19 | fragment.lifecycle.addObserver(object : LifecycleObserver { 20 | @OnLifecycleEvent(Lifecycle.Event.ON_DESTROY) 21 | fun onDestroy() { 22 | _value = null 23 | } 24 | }) 25 | } 26 | 27 | override fun getValue(thisRef: Fragment, property: KProperty<*>): T { 28 | return _value ?: throw IllegalStateException( 29 | "should never call auto-cleared-value get when it might not be available" 30 | ) 31 | } 32 | 33 | override fun setValue(thisRef: Fragment, property: KProperty<*>, value: T) { 34 | _value = value 35 | } 36 | } 37 | 38 | /** 39 | * Creates an [AutoClearedValue] associated with this fragment. 40 | */ 41 | fun Fragment.autoCleared() = AutoClearedValue(this) -------------------------------------------------------------------------------- /app/src/main/kotlin/com/czbix/v2ex/ui/BindingAdapters.kt: -------------------------------------------------------------------------------- 1 | package com.czbix.v2ex.ui 2 | 3 | import android.view.View 4 | import androidx.databinding.BindingAdapter 5 | 6 | @BindingAdapter("visibility") 7 | fun View.setVisibility(value: Boolean) { 8 | visibility = if (value) View.VISIBLE else View.GONE 9 | } 10 | 11 | @BindingAdapter("visible") 12 | fun View.setVisible(value: Boolean) { 13 | visibility = if (value) View.VISIBLE else View.INVISIBLE 14 | } 15 | -------------------------------------------------------------------------------- /app/src/main/kotlin/com/czbix/v2ex/ui/DebugActivity.kt: -------------------------------------------------------------------------------- 1 | package com.czbix.v2ex.ui 2 | 3 | import android.annotation.SuppressLint 4 | import android.content.Intent 5 | import android.os.Bundle 6 | import androidx.appcompat.app.AppCompatActivity 7 | import android.view.View 8 | import android.widget.Button 9 | import android.widget.LinearLayout 10 | 11 | import com.czbix.v2ex.model.Topic 12 | 13 | class DebugActivity : AppCompatActivity() { 14 | private lateinit var mLayout: LinearLayout 15 | 16 | override fun onCreate(savedInstanceState: Bundle?) { 17 | super.onCreate(savedInstanceState) 18 | mLayout = LinearLayout(this) 19 | setContentView(mLayout) 20 | 21 | initDebugItem() 22 | } 23 | 24 | @SuppressLint("SetTextI18n") 25 | private fun initDebugItem() { 26 | val button = Button(this) 27 | button.text = "Sandbox Topic" 28 | button.setOnClickListener { 29 | // val intent = Intent(this@DebugActivity, TopicActivity::class.java) 30 | } 31 | mLayout.addView(button) 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /app/src/main/kotlin/com/czbix/v2ex/ui/ExHolder.kt: -------------------------------------------------------------------------------- 1 | package com.czbix.v2ex.ui 2 | 3 | import android.view.View 4 | import androidx.annotation.CallSuper 5 | import androidx.annotation.IdRes 6 | import com.airbnb.epoxy.EpoxyHolder 7 | import com.bumptech.glide.Glide 8 | import kotlin.properties.ReadOnlyProperty 9 | import kotlin.reflect.KProperty 10 | 11 | abstract class ExHolder : EpoxyHolder() { 12 | lateinit var view: T 13 | val glide by lazy(LazyThreadSafetyMode.NONE) { Glide.with(view) } 14 | 15 | @CallSuper 16 | override fun bindView(itemView: View) { 17 | @Suppress("UNCHECKED_CAST") 18 | view = itemView as T 19 | 20 | onCreate() 21 | } 22 | 23 | protected open fun onCreate() { 24 | } 25 | 26 | protected fun bind(@IdRes id: Int): ReadOnlyProperty, V> { 27 | return Lazy { holder: ExHolder, prop -> 28 | holder.view.findViewById(id) as V? 29 | ?: throw IllegalStateException("View ID $id for '${prop.name}' not found.") 30 | } 31 | } 32 | 33 | /** 34 | * Taken from Kotterknife. 35 | * https://github.com/JakeWharton/kotterknife 36 | */ 37 | private class Lazy( 38 | private val initializer: (ExHolder, KProperty<*>) -> V 39 | ) : ReadOnlyProperty, V> { 40 | private object EMPTY 41 | 42 | private var value: Any? = EMPTY 43 | 44 | override fun getValue(thisRef: ExHolder, property: KProperty<*>): V { 45 | if (value == EMPTY) { 46 | value = initializer(thisRef, property) 47 | } 48 | @Suppress("UNCHECKED_CAST") 49 | return value as V 50 | } 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /app/src/main/kotlin/com/czbix/v2ex/ui/SettingsActivity.kt: -------------------------------------------------------------------------------- 1 | package com.czbix.v2ex.ui 2 | 3 | import android.os.Bundle 4 | import android.view.MenuItem 5 | import androidx.activity.viewModels 6 | import androidx.lifecycle.ViewModelProvider 7 | import com.czbix.v2ex.ui.model.NightModeViewModel 8 | import com.czbix.v2ex.ui.settings.PrefsFragment 9 | import javax.inject.Inject 10 | 11 | class SettingsActivity : BaseActivity() { 12 | @Inject 13 | lateinit var viewModelFactory: ViewModelProvider.Factory 14 | 15 | private val nightModeViewModel: NightModeViewModel by viewModels { 16 | viewModelFactory 17 | } 18 | 19 | override fun onCreate(savedInstanceState: Bundle?) { 20 | super.onCreate(savedInstanceState) 21 | 22 | initNightMode(nightModeViewModel.nightMode) 23 | 24 | supportFragmentManager.beginTransaction().replace(android.R.id.content, PrefsFragment()).commit() 25 | supportActionBar!!.setDisplayHomeAsUpEnabled(true) 26 | } 27 | 28 | override fun onOptionsItemSelected(item: MenuItem): Boolean { 29 | if (item.itemId == android.R.id.home) { 30 | onBackPressed() 31 | return true 32 | } 33 | 34 | return super.onOptionsItemSelected(item) 35 | } 36 | 37 | // override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { 38 | // super.onActivityResult(requestCode, resultCode, data) 39 | // mFragment.onActivityResult(requestCode, resultCode, data) 40 | // } 41 | } 42 | -------------------------------------------------------------------------------- /app/src/main/kotlin/com/czbix/v2ex/ui/adapter/TopicController.kt: -------------------------------------------------------------------------------- 1 | package com.czbix.v2ex.ui.adapter 2 | 3 | import android.graphics.drawable.Drawable 4 | import com.airbnb.epoxy.EpoxyAttribute 5 | import com.airbnb.epoxy.EpoxyModelClass 6 | import com.airbnb.epoxy.EpoxyModelWithHolder 7 | import com.airbnb.epoxy.Typed2EpoxyController 8 | import com.airbnb.epoxy.preload.Preloadable 9 | import com.bumptech.glide.RequestBuilder 10 | import com.bumptech.glide.RequestManager 11 | import com.czbix.v2ex.R 12 | import com.czbix.v2ex.model.Topic 13 | import com.czbix.v2ex.ui.ExHolder 14 | import com.czbix.v2ex.ui.widget.AvatarView 15 | import com.czbix.v2ex.ui.widget.TopicView 16 | 17 | class TopicController(private val mListener: TopicView.OnTopicActionListener) 18 | : Typed2EpoxyController, Set>() { 19 | var data: List = emptyList() 20 | var readedSet: Set = emptySet() 21 | 22 | override fun buildModels(data: List, readedSet: Set) { 23 | this.data = data 24 | this.readedSet = readedSet 25 | if (data.isEmpty()) { 26 | return 27 | } 28 | 29 | data.forEach { topic -> 30 | topicControllerTopic { 31 | id(topic.id) 32 | listener(this@TopicController.mListener) 33 | topic(topic) 34 | readed(topic.id in readedSet) 35 | } 36 | } 37 | } 38 | 39 | @EpoxyModelClass(layout = R.layout.view_topic) 40 | abstract class TopicModel : EpoxyModelWithHolder() { 41 | @EpoxyAttribute 42 | lateinit var topic: Topic 43 | 44 | @EpoxyAttribute 45 | var readed: Boolean = false 46 | 47 | @EpoxyAttribute(EpoxyAttribute.Option.DoNotHash) 48 | lateinit var listener: TopicView.OnTopicActionListener 49 | 50 | override fun bind(holder: Holder) { 51 | holder.view.setListener(listener) 52 | holder.view.fillData(holder.glide, topic, readed) 53 | } 54 | 55 | override fun unbind(holder: Holder) { 56 | holder.view.clear(holder.glide) 57 | } 58 | 59 | fun getImgRequest(glide: RequestManager, avatarView: AvatarView): RequestBuilder { 60 | return avatarView.getImgRequest(glide, topic.member!!.avatar) 61 | } 62 | 63 | inner class Holder : ExHolder(), Preloadable { 64 | override val viewsToPreload by lazy { 65 | listOf(view.mAvatar) 66 | } 67 | } 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /app/src/main/kotlin/com/czbix/v2ex/ui/common/RetryCallback.kt: -------------------------------------------------------------------------------- 1 | package com.czbix.v2ex.ui.common 2 | 3 | interface RetryCallback { 4 | fun retry() 5 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/com/czbix/v2ex/ui/fragment/TwoFactorAuthDialog.kt: -------------------------------------------------------------------------------- 1 | package com.czbix.v2ex.ui.fragment 2 | 3 | import android.app.Dialog 4 | import android.content.DialogInterface 5 | import android.os.Bundle 6 | import android.widget.EditText 7 | import androidx.appcompat.app.AlertDialog 8 | import androidx.fragment.app.DialogFragment 9 | import com.czbix.v2ex.R 10 | import com.czbix.v2ex.event.BaseEvent 11 | import com.czbix.v2ex.helper.RxBus 12 | 13 | class TwoFactorAuthDialog : DialogFragment(), DialogInterface.OnClickListener { 14 | private lateinit var editText: EditText 15 | 16 | override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { 17 | val inflater = activity!!.layoutInflater 18 | val layout = inflater.inflate(R.layout.view_edittext, null) 19 | editText = layout.findViewById(R.id.edit_text) 20 | 21 | return AlertDialog.Builder(context!!).apply { 22 | setTitle(R.string.title_two_factor_auth) 23 | setView(layout) 24 | setNegativeButton(R.string.action_cancel, this@TwoFactorAuthDialog) 25 | // set click listener later to avoid auto dismiss 26 | setPositiveButton(R.string.action_sign_in, null) 27 | }.create() 28 | } 29 | 30 | override fun onStart() { 31 | super.onStart() 32 | 33 | val dialog = dialog as AlertDialog 34 | 35 | dialog.getButton(DialogInterface.BUTTON_POSITIVE).setOnClickListener { 36 | onClick(dialog, DialogInterface.BUTTON_POSITIVE) 37 | } 38 | } 39 | 40 | override fun onClick(dialog: DialogInterface, which: Int) { 41 | when (which) { 42 | DialogInterface.BUTTON_NEGATIVE -> dialog.cancel() 43 | DialogInterface.BUTTON_POSITIVE -> { 44 | val code = editText.text.toString() 45 | if (code.length != 6) { 46 | editText.error = getString(R.string.error_invalid_auth_code) 47 | return 48 | } 49 | 50 | // dialog will auto close after click, check code format at outside 51 | RxBus.post(TwoFactorAuthEvent(code)) 52 | dismiss() 53 | } 54 | } 55 | } 56 | 57 | override fun onCancel(dialog: DialogInterface) { 58 | super.onCancel(dialog) 59 | 60 | RxBus.post(TwoFactorAuthEvent()) 61 | } 62 | 63 | class TwoFactorAuthEvent(val code: String?) : BaseEvent() { 64 | constructor() : this(null) 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /app/src/main/kotlin/com/czbix/v2ex/ui/loader/TopicListLoader.kt: -------------------------------------------------------------------------------- 1 | package com.czbix.v2ex.ui.loader 2 | 3 | import android.content.Context 4 | import androidx.collection.ArraySet 5 | import com.czbix.v2ex.db.TopicRecordDao 6 | import com.czbix.v2ex.model.Page 7 | import com.czbix.v2ex.model.Topic 8 | import com.czbix.v2ex.network.RequestHelper 9 | import kotlinx.coroutines.runBlocking 10 | 11 | class TopicListLoader( 12 | context: Context, 13 | private val mPage: Page, 14 | private val dao: TopicRecordDao 15 | ) : AsyncTaskLoader(context) { 16 | @Throws(Exception::class) 17 | override fun loadInBackgroundWithException(): TopicList { 18 | val topics = runBlocking { 19 | RequestHelper.getTopics(mPage) 20 | } 21 | 22 | val readed = topics.filter { topic -> 23 | val lastRead = dao.getLastReadComment(topic.id) ?: 0 24 | lastRead >= topic.replyCount 25 | }.map { 26 | it.id 27 | } 28 | 29 | topics.readed = ArraySet(readed) 30 | 31 | return topics 32 | } 33 | 34 | class TopicList( 35 | list: List, 36 | val isFavorited: Boolean, 37 | val onceToken: String? = null 38 | ): List by list { 39 | lateinit var readed: Set 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /app/src/main/kotlin/com/czbix/v2ex/ui/model/NightModeViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.czbix.v2ex.ui.model 2 | 3 | import androidx.lifecycle.LiveData 4 | import androidx.lifecycle.ViewModel 5 | import androidx.lifecycle.map 6 | import com.czbix.v2ex.model.PreferenceStorage 7 | import javax.inject.Inject 8 | 9 | enum class NightMode(val key: String) { 10 | LIGHT("light"), 11 | DARK("dark"), 12 | SYSTEM("system"); 13 | 14 | companion object { 15 | fun fromKey(key: String): NightMode { 16 | for (mode in values()) { 17 | if (mode.key == key) { 18 | return mode 19 | } 20 | } 21 | 22 | error("Unknown key: $key") 23 | } 24 | } 25 | } 26 | 27 | class NightModeViewModel @Inject constructor( 28 | private val nightModeDelegate: NightModeDelegate 29 | ) : ViewModel(), NightModeDelegate by nightModeDelegate 30 | 31 | interface NightModeDelegate { 32 | val nightMode: LiveData 33 | val currentNightMode: NightMode 34 | } 35 | 36 | class NightModeDelegateImpl @Inject constructor( 37 | private val prefs: PreferenceStorage 38 | ) : NightModeDelegate { 39 | override val nightMode by lazy { 40 | prefs.nightModeObservable.map { 41 | NightMode.fromKey(it) 42 | } 43 | } 44 | 45 | override val currentNightMode 46 | get() = NightMode.fromKey(prefs.nightMode) 47 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/com/czbix/v2ex/ui/settings/SettingsModule.kt: -------------------------------------------------------------------------------- 1 | package com.czbix.v2ex.ui.settings 2 | 3 | import androidx.lifecycle.ViewModel 4 | import com.czbix.v2ex.inject.FragmentScoped 5 | import com.czbix.v2ex.inject.ViewModelKey 6 | import dagger.Binds 7 | import dagger.Module 8 | import dagger.android.ContributesAndroidInjector 9 | import dagger.multibindings.IntoMap 10 | 11 | @Module 12 | abstract class SettingsModule { 13 | @FragmentScoped 14 | @ContributesAndroidInjector 15 | abstract fun prefsFragment(): PrefsFragment 16 | 17 | @Binds 18 | @IntoMap 19 | @ViewModelKey(SettingsViewModel::class) 20 | abstract fun bindSettingsViewModel(viewModel: SettingsViewModel): ViewModel 21 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/com/czbix/v2ex/ui/settings/SettingsViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.czbix.v2ex.ui.settings 2 | 3 | import androidx.lifecycle.ViewModel 4 | import com.czbix.v2ex.model.PreferenceStorage 5 | import com.czbix.v2ex.ui.model.NightModeDelegate 6 | import javax.inject.Inject 7 | 8 | class SettingsViewModel @Inject constructor( 9 | private val nightModeDelegate: NightModeDelegate, 10 | private val prefs: PreferenceStorage 11 | ) : ViewModel() { 12 | val nightMode 13 | get() = nightModeDelegate.currentNightMode.key 14 | 15 | fun setNightMode(key: String) { 16 | prefs.nightMode = key 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /app/src/main/kotlin/com/czbix/v2ex/ui/widget/AvatarView.kt: -------------------------------------------------------------------------------- 1 | package com.czbix.v2ex.ui.widget 2 | 3 | import android.content.Context 4 | import android.graphics.drawable.Drawable 5 | import android.util.AttributeSet 6 | import androidx.appcompat.widget.AppCompatImageView 7 | import com.airbnb.epoxy.preload.ViewMetadata 8 | import com.bumptech.glide.RequestBuilder 9 | import com.bumptech.glide.RequestManager 10 | import com.czbix.v2ex.R 11 | import com.czbix.v2ex.db.Member 12 | import com.czbix.v2ex.model.Avatar 13 | import com.czbix.v2ex.util.withCrossFade 14 | 15 | class AvatarView @JvmOverloads constructor( 16 | context: Context, 17 | attrs: AttributeSet? = null, 18 | defStyleAttr: Int = 0 19 | ) : AppCompatImageView(context, attrs, defStyleAttr) { 20 | private val realSize by lazy { 21 | layoutParams.width - paddingTop * 2 22 | } 23 | 24 | fun setAvatar(glide: RequestManager, avatar: Avatar?) { 25 | if (avatar == null) { 26 | glide.clear(this) 27 | return 28 | } 29 | 30 | getImgRequest(glide, avatar).into(this) 31 | } 32 | 33 | fun getImgRequest(glide: RequestManager, avatar: Avatar): RequestBuilder { 34 | val size = realSize 35 | val url = avatar.getUrlByPx(size) 36 | 37 | return glide.load(url).placeholder(R.drawable.avatar_default) 38 | .override(size, size).fitCenter() 39 | .withCrossFade() 40 | } 41 | 42 | interface OnAvatarActionListener { 43 | fun onMemberClick(member: Member) 44 | } 45 | 46 | class Metadata( 47 | val avatarView: AvatarView 48 | ) : ViewMetadata 49 | } 50 | -------------------------------------------------------------------------------- /app/src/main/kotlin/com/czbix/v2ex/util/CoroutineUtils.kt: -------------------------------------------------------------------------------- 1 | package com.czbix.v2ex.util 2 | 3 | import kotlinx.coroutines.* 4 | import kotlinx.coroutines.channels.ReceiveChannel 5 | import kotlinx.coroutines.channels.consumeEach 6 | import kotlinx.coroutines.channels.produce 7 | import kotlin.coroutines.CoroutineContext 8 | import kotlin.coroutines.EmptyCoroutineContext 9 | 10 | 11 | suspend fun ReceiveChannel.debounce( 12 | wait: Long, 13 | scope: CoroutineScope = GlobalScope, 14 | context: CoroutineContext = EmptyCoroutineContext 15 | ): ReceiveChannel = scope.produce(context) { 16 | var lastTimeout: Job? = null 17 | consumeEach { 18 | lastTimeout?.cancel() 19 | lastTimeout = launch { 20 | delay(wait) 21 | send(it) 22 | } 23 | } 24 | lastTimeout?.join() 25 | } 26 | 27 | fun ReceiveChannel.throttle( 28 | wait: Long, 29 | scope: CoroutineScope = GlobalScope, 30 | context: CoroutineContext = EmptyCoroutineContext 31 | ): ReceiveChannel = scope.produce(context) { 32 | var nextTime = 0L 33 | consumeEach { 34 | val curTime = System.currentTimeMillis() 35 | if (curTime >= nextTime) { 36 | nextTime = curTime + wait 37 | send(it) 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /app/src/main/kotlin/com/czbix/v2ex/util/CrashlyticsTree.kt: -------------------------------------------------------------------------------- 1 | package com.czbix.v2ex.util 2 | 3 | import android.util.Log 4 | import com.google.firebase.crashlytics.FirebaseCrashlytics 5 | import timber.log.Timber 6 | 7 | class CrashlyticsTree : Timber.Tree() { 8 | override fun log(priority: Int, tag: String?, message: String, t: Throwable?) { 9 | if (priority == Log.VERBOSE) { 10 | return 11 | } 12 | 13 | val crashlytics = FirebaseCrashlytics.getInstance() 14 | crashlytics.log(message) 15 | if (t != null) { 16 | crashlytics.recordException(t) 17 | } 18 | } 19 | } 20 | 21 | -------------------------------------------------------------------------------- /app/src/main/kotlin/com/czbix/v2ex/util/ExceptionUtils.kt: -------------------------------------------------------------------------------- 1 | package com.czbix.v2ex.util 2 | 3 | import android.widget.Toast 4 | import androidx.fragment.app.Fragment 5 | import com.czbix.v2ex.R 6 | import com.czbix.v2ex.common.exception.* 7 | import com.czbix.v2ex.network.HttpStatus 8 | import timber.log.Timber 9 | 10 | object ExceptionUtils { 11 | /** 12 | * warp exception in [.handleException] with [FatalException] 13 | */ 14 | @JvmStatic 15 | @Deprecated("Should not crash the app") 16 | fun handleExceptionNoCatch(fragment: Fragment, ex: Exception): Boolean { 17 | try { 18 | return handleException(fragment, ex) 19 | } catch (e: Exception) { 20 | throw FatalException(e) 21 | } 22 | } 23 | 24 | @JvmStatic 25 | fun handleException(fragment: Fragment, e: Exception): Boolean { 26 | val activity = fragment.activity 27 | var needFinishActivity = false 28 | val stringId: Int 29 | 30 | when (e) { 31 | is RestrictedException -> { 32 | needFinishActivity = true 33 | stringId = R.string.toast_access_restricted 34 | } 35 | is UnauthorizedException -> { 36 | needFinishActivity = true 37 | stringId = R.string.toast_need_sign_in 38 | } 39 | is ConnectionException -> { 40 | stringId = R.string.toast_connection_exception 41 | } 42 | is RemoteException -> { 43 | stringId = R.string.toast_remote_exception 44 | } 45 | is RequestException -> { 46 | if (e.isShouldLogged) { 47 | Timber.e(e) 48 | } 49 | stringId = when (e.code) { 50 | HttpStatus.SC_FORBIDDEN -> R.string.toast_access_denied 51 | else -> R.string.toast_unknown_error 52 | } 53 | } 54 | is IllegalStateException -> { 55 | var logException = true 56 | if (e is ExIllegalStateException) { 57 | logException = e.shouldLogged 58 | } 59 | if (logException) { 60 | Timber.e(e) 61 | } else { 62 | Timber.i(e) 63 | } 64 | 65 | stringId = R.string.toast_parse_failed 66 | } 67 | is RuntimeException -> { 68 | Timber.e(e) 69 | stringId = R.string.toast_parse_failed 70 | } 71 | else -> { 72 | Timber.e(e,"Unknown error") 73 | stringId = R.string.toast_unknown_error 74 | } 75 | } 76 | 77 | Toast.makeText(activity, stringId, Toast.LENGTH_LONG).show() 78 | 79 | return needFinishActivity 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /app/src/main/kotlin/com/czbix/v2ex/util/GsonUtils.kt: -------------------------------------------------------------------------------- 1 | package com.czbix.v2ex.util 2 | 3 | import com.google.gson.FieldNamingStrategy 4 | import com.google.gson.Gson 5 | import com.google.gson.GsonBuilder 6 | import com.google.gson.reflect.TypeToken 7 | import java.io.Reader 8 | import java.lang.reflect.Field 9 | import java.util.regex.Pattern 10 | 11 | private class AndroidFieldNamingStrategy : FieldNamingStrategy { 12 | override fun translateName(f: Field): String { 13 | if (f.name.startsWith("m")) { 14 | return handleWords(f.name.substring(1)) 15 | } else { 16 | return f.name 17 | } 18 | } 19 | 20 | private fun handleWords(fieldName: String): String { 21 | val words = UPPERCASE_PATTERN.split(fieldName) 22 | val sb = StringBuilder() 23 | for (word in words) { 24 | if (sb.length > 0) { 25 | sb.append(JSON_WORD_DELIMITER) 26 | } 27 | sb.append(word.toLowerCase()) 28 | } 29 | return sb.toString() 30 | } 31 | 32 | companion object { 33 | private val JSON_WORD_DELIMITER = "_" 34 | 35 | private val UPPERCASE_PATTERN = Pattern.compile("(?=\\p{Lu})") 36 | } 37 | } 38 | 39 | 40 | val GSON: Gson = GsonBuilder().apply { 41 | setFieldNamingStrategy(AndroidFieldNamingStrategy()) 42 | }.create() 43 | 44 | inline fun String.fromJson(): T { 45 | return GSON.fromJson(this, T::class.java) 46 | } 47 | 48 | @Suppress("UNUSED_PARAMETER") 49 | inline fun String.fromJson(isGenericType: Boolean): T { 50 | return GSON.fromJson(this, object : TypeToken(){}.type) 51 | } 52 | 53 | inline fun Reader.fromJson(): T { 54 | return GSON.fromJson(this, T::class.java) 55 | } 56 | 57 | @Suppress("UNUSED_PARAMETER") 58 | inline fun Reader.fromJson(isGenericType: Boolean): T { 59 | return GSON.fromJson(this, object : TypeToken(){}.type) 60 | } 61 | 62 | inline fun T.toJson(): String { 63 | return GSON.toJson(this, T::class.java) 64 | } 65 | 66 | @Suppress("UNUSED_PARAMETER") 67 | inline fun T.toJson(isGenericType: Boolean): String { 68 | return GSON.toJson(this, object : TypeToken(){}.type) 69 | } 70 | -------------------------------------------------------------------------------- /app/src/main/kotlin/com/czbix/v2ex/util/IoUtils.kt: -------------------------------------------------------------------------------- 1 | package com.czbix.v2ex.util 2 | 3 | import com.czbix.v2ex.AppCtx 4 | 5 | import java.io.File 6 | 7 | object IoUtils { 8 | private var cacheDir: File 9 | 10 | init { 11 | cacheDir = AppCtx.instance.cacheDir 12 | } 13 | 14 | @JvmStatic 15 | val webCachePath: File by lazy { 16 | File(cacheDir, "webCache") 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /app/src/main/kotlin/com/czbix/v2ex/util/LiveDataUtils.kt: -------------------------------------------------------------------------------- 1 | package com.czbix.v2ex.util 2 | 3 | import androidx.lifecycle.LiveData 4 | import androidx.lifecycle.MediatorLiveData 5 | import androidx.lifecycle.MutableLiveData 6 | import com.czbix.v2ex.model.EmptyLiveData 7 | import com.czbix.v2ex.model.Resource 8 | import kotlinx.coroutines.CoroutineScope 9 | import kotlinx.coroutines.GlobalScope 10 | import kotlinx.coroutines.launch 11 | import kotlin.coroutines.CoroutineContext 12 | import kotlin.coroutines.EmptyCoroutineContext 13 | 14 | 15 | fun liveData( 16 | scope: CoroutineScope = GlobalScope, 17 | context: CoroutineContext = EmptyCoroutineContext, 18 | block: suspend () -> T 19 | ): LiveData { 20 | val liveData = MutableLiveData() 21 | scope.launch(context) { 22 | val data = block() 23 | liveData.postValue(data) 24 | } 25 | 26 | return liveData 27 | } 28 | 29 | fun emptyLiveData() = EmptyLiveData.create() 30 | 31 | fun LiveData.then(condition: ((T) -> Boolean)? = null, block: () -> LiveData): LiveData { 32 | var conditionMeet = false 33 | var valueEmitted = false 34 | 35 | val result = MediatorLiveData() 36 | val liveData = block() 37 | 38 | val emit = { it: R? -> 39 | if (conditionMeet && valueEmitted) { 40 | result.value = it ?: liveData.value 41 | } 42 | } 43 | 44 | result.addSource(this) { 45 | if (condition == null || condition(it)) { 46 | result.removeSource(this) 47 | 48 | conditionMeet = true 49 | emit(null) 50 | } 51 | } 52 | result.addSource(liveData) { 53 | if (!valueEmitted) { 54 | valueEmitted = true 55 | } 56 | emit(it) 57 | } 58 | 59 | return result 60 | } 61 | 62 | fun LiveData>.then(block: () -> LiveData): LiveData { 63 | return this.then({ it.status == Resource.Status.SUCCESS}, block) 64 | } 65 | -------------------------------------------------------------------------------- /app/src/main/kotlin/com/czbix/v2ex/util/RxUtils.kt: -------------------------------------------------------------------------------- 1 | package com.czbix.v2ex.util 2 | 3 | import io.reactivex.Single 4 | import io.reactivex.android.schedulers.AndroidSchedulers 5 | import io.reactivex.disposables.Disposable 6 | import io.reactivex.internal.observers.ConsumerSingleObserver 7 | 8 | fun Single.await(onSuccess: (T) -> Unit): Disposable { 9 | return this.observeOn(AndroidSchedulers.mainThread()).subscribe(onSuccess) 10 | } 11 | 12 | fun Single.await(onSuccess: (T) -> Unit, onError: (Throwable) -> Unit): Disposable { 13 | return this.observeOn(AndroidSchedulers.mainThread()).subscribe(onSuccess, onError) 14 | } 15 | 16 | fun MutableList.dispose() { 17 | this.forEach { it.dispose() } 18 | this.clear() 19 | } 20 | 21 | /** 22 | * @see io.reactivex.exceptions.Exceptions.propagate 23 | */ 24 | fun Single.result(): T { 25 | try { 26 | return this.blockingGet() 27 | } catch (e: RuntimeException) { 28 | // unwarp RuntimeException for Exceptions.propagate 29 | val cause = e.cause 30 | when { 31 | cause == null -> throw e 32 | cause === e -> throw e 33 | else -> throw cause 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /app/src/main/res/color/btn_reply_submit.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /app/src/main/res/drawable-anydpi/ic_arrow_back_black_24dp.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 10 | -------------------------------------------------------------------------------- /app/src/main/res/drawable-anydpi/ic_close_black_24dp.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 10 | -------------------------------------------------------------------------------- /app/src/main/res/drawable-anydpi/ic_dashboard_black_24dp.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 10 | -------------------------------------------------------------------------------- /app/src/main/res/drawable-anydpi/ic_explore_black_24dp.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 10 | -------------------------------------------------------------------------------- /app/src/main/res/drawable-anydpi/ic_favorite_black_24dp.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 10 | -------------------------------------------------------------------------------- /app/src/main/res/drawable-anydpi/ic_feedback_black_24dp.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 10 | -------------------------------------------------------------------------------- /app/src/main/res/drawable-anydpi/ic_info_outline_black_24dp.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 10 | -------------------------------------------------------------------------------- /app/src/main/res/drawable-anydpi/ic_notifications_black_24dp.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 10 | -------------------------------------------------------------------------------- /app/src/main/res/drawable-anydpi/ic_notifications_none_black_24dp.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 10 | -------------------------------------------------------------------------------- /app/src/main/res/drawable-anydpi/ic_send_black_24dp.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 10 | -------------------------------------------------------------------------------- /app/src/main/res/drawable-anydpi/ic_settings_black_24dp.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 10 | -------------------------------------------------------------------------------- /app/src/main/res/drawable-hdpi-v23/window_background_statusbar_toolbar_tab.9.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CzBiX/v2ex-android/e18b6722babeaf771a51c5b714d234edbf6f6fa1/app/src/main/res/drawable-hdpi-v23/window_background_statusbar_toolbar_tab.9.png -------------------------------------------------------------------------------- /app/src/main/res/drawable-hdpi/ic_card_giftcard_white_24dp.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CzBiX/v2ex-android/e18b6722babeaf771a51c5b714d234edbf6f6fa1/app/src/main/res/drawable-hdpi/ic_card_giftcard_white_24dp.png -------------------------------------------------------------------------------- /app/src/main/res/drawable-hdpi/ic_favorite_border_white_24dp.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CzBiX/v2ex-android/e18b6722babeaf771a51c5b714d234edbf6f6fa1/app/src/main/res/drawable-hdpi/ic_favorite_border_white_24dp.png -------------------------------------------------------------------------------- /app/src/main/res/drawable-hdpi/ic_favorite_white_24dp.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CzBiX/v2ex-android/e18b6722babeaf771a51c5b714d234edbf6f6fa1/app/src/main/res/drawable-hdpi/ic_favorite_white_24dp.png -------------------------------------------------------------------------------- /app/src/main/res/drawable-hdpi/ic_keyboard_arrow_down_white_24dp.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CzBiX/v2ex-android/e18b6722babeaf771a51c5b714d234edbf6f6fa1/app/src/main/res/drawable-hdpi/ic_keyboard_arrow_down_white_24dp.png -------------------------------------------------------------------------------- /app/src/main/res/drawable-hdpi/ic_notifications_white_24dp.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CzBiX/v2ex-android/e18b6722babeaf771a51c5b714d234edbf6f6fa1/app/src/main/res/drawable-hdpi/ic_notifications_white_24dp.png -------------------------------------------------------------------------------- /app/src/main/res/drawable-hdpi/ic_refresh_white_24dp.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CzBiX/v2ex-android/e18b6722babeaf771a51c5b714d234edbf6f6fa1/app/src/main/res/drawable-hdpi/ic_refresh_white_24dp.png -------------------------------------------------------------------------------- /app/src/main/res/drawable-hdpi/ic_search_white_24dp.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CzBiX/v2ex-android/e18b6722babeaf771a51c5b714d234edbf6f6fa1/app/src/main/res/drawable-hdpi/ic_search_white_24dp.png -------------------------------------------------------------------------------- /app/src/main/res/drawable-hdpi/ic_sync_disabled_white_24dp.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CzBiX/v2ex-android/e18b6722babeaf771a51c5b714d234edbf6f6fa1/app/src/main/res/drawable-hdpi/ic_sync_disabled_white_24dp.png -------------------------------------------------------------------------------- /app/src/main/res/drawable-hdpi/ic_sync_problem_white_24dp.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CzBiX/v2ex-android/e18b6722babeaf771a51c5b714d234edbf6f6fa1/app/src/main/res/drawable-hdpi/ic_sync_problem_white_24dp.png -------------------------------------------------------------------------------- /app/src/main/res/drawable-hdpi/ic_sync_white_24dp.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CzBiX/v2ex-android/e18b6722babeaf771a51c5b714d234edbf6f6fa1/app/src/main/res/drawable-hdpi/ic_sync_white_24dp.png -------------------------------------------------------------------------------- /app/src/main/res/drawable-hdpi/window_background_statusbar_toolbar_tab.9.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CzBiX/v2ex-android/e18b6722babeaf771a51c5b714d234edbf6f6fa1/app/src/main/res/drawable-hdpi/window_background_statusbar_toolbar_tab.9.png -------------------------------------------------------------------------------- /app/src/main/res/drawable-mdpi-v23/window_background_statusbar_toolbar_tab.9.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CzBiX/v2ex-android/e18b6722babeaf771a51c5b714d234edbf6f6fa1/app/src/main/res/drawable-mdpi-v23/window_background_statusbar_toolbar_tab.9.png -------------------------------------------------------------------------------- /app/src/main/res/drawable-mdpi/avatar_default.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CzBiX/v2ex-android/e18b6722babeaf771a51c5b714d234edbf6f6fa1/app/src/main/res/drawable-mdpi/avatar_default.png -------------------------------------------------------------------------------- /app/src/main/res/drawable-mdpi/ic_card_giftcard_white_24dp.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CzBiX/v2ex-android/e18b6722babeaf771a51c5b714d234edbf6f6fa1/app/src/main/res/drawable-mdpi/ic_card_giftcard_white_24dp.png -------------------------------------------------------------------------------- /app/src/main/res/drawable-mdpi/ic_favorite_border_white_24dp.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CzBiX/v2ex-android/e18b6722babeaf771a51c5b714d234edbf6f6fa1/app/src/main/res/drawable-mdpi/ic_favorite_border_white_24dp.png -------------------------------------------------------------------------------- /app/src/main/res/drawable-mdpi/ic_favorite_white_24dp.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CzBiX/v2ex-android/e18b6722babeaf771a51c5b714d234edbf6f6fa1/app/src/main/res/drawable-mdpi/ic_favorite_white_24dp.png -------------------------------------------------------------------------------- /app/src/main/res/drawable-mdpi/ic_keyboard_arrow_down_white_24dp.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CzBiX/v2ex-android/e18b6722babeaf771a51c5b714d234edbf6f6fa1/app/src/main/res/drawable-mdpi/ic_keyboard_arrow_down_white_24dp.png -------------------------------------------------------------------------------- /app/src/main/res/drawable-mdpi/ic_notifications_white_24dp.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CzBiX/v2ex-android/e18b6722babeaf771a51c5b714d234edbf6f6fa1/app/src/main/res/drawable-mdpi/ic_notifications_white_24dp.png -------------------------------------------------------------------------------- /app/src/main/res/drawable-mdpi/ic_refresh_white_24dp.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CzBiX/v2ex-android/e18b6722babeaf771a51c5b714d234edbf6f6fa1/app/src/main/res/drawable-mdpi/ic_refresh_white_24dp.png -------------------------------------------------------------------------------- /app/src/main/res/drawable-mdpi/ic_search_white_24dp.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CzBiX/v2ex-android/e18b6722babeaf771a51c5b714d234edbf6f6fa1/app/src/main/res/drawable-mdpi/ic_search_white_24dp.png -------------------------------------------------------------------------------- /app/src/main/res/drawable-mdpi/ic_sync_disabled_white_24dp.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CzBiX/v2ex-android/e18b6722babeaf771a51c5b714d234edbf6f6fa1/app/src/main/res/drawable-mdpi/ic_sync_disabled_white_24dp.png -------------------------------------------------------------------------------- /app/src/main/res/drawable-mdpi/ic_sync_problem_white_24dp.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CzBiX/v2ex-android/e18b6722babeaf771a51c5b714d234edbf6f6fa1/app/src/main/res/drawable-mdpi/ic_sync_problem_white_24dp.png -------------------------------------------------------------------------------- /app/src/main/res/drawable-mdpi/ic_sync_white_24dp.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CzBiX/v2ex-android/e18b6722babeaf771a51c5b714d234edbf6f6fa1/app/src/main/res/drawable-mdpi/ic_sync_white_24dp.png -------------------------------------------------------------------------------- /app/src/main/res/drawable-mdpi/window_background_statusbar_toolbar_tab.9.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CzBiX/v2ex-android/e18b6722babeaf771a51c5b714d234edbf6f6fa1/app/src/main/res/drawable-mdpi/window_background_statusbar_toolbar_tab.9.png -------------------------------------------------------------------------------- /app/src/main/res/drawable-sw600dp-hdpi-v23/window_background_statusbar_toolbar_tab.9.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CzBiX/v2ex-android/e18b6722babeaf771a51c5b714d234edbf6f6fa1/app/src/main/res/drawable-sw600dp-hdpi-v23/window_background_statusbar_toolbar_tab.9.png -------------------------------------------------------------------------------- /app/src/main/res/drawable-sw600dp-hdpi/window_background_statusbar_toolbar_tab.9.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CzBiX/v2ex-android/e18b6722babeaf771a51c5b714d234edbf6f6fa1/app/src/main/res/drawable-sw600dp-hdpi/window_background_statusbar_toolbar_tab.9.png -------------------------------------------------------------------------------- /app/src/main/res/drawable-sw600dp-mdpi-v23/window_background_statusbar_toolbar_tab.9.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CzBiX/v2ex-android/e18b6722babeaf771a51c5b714d234edbf6f6fa1/app/src/main/res/drawable-sw600dp-mdpi-v23/window_background_statusbar_toolbar_tab.9.png -------------------------------------------------------------------------------- /app/src/main/res/drawable-sw600dp-mdpi/window_background_statusbar_toolbar_tab.9.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CzBiX/v2ex-android/e18b6722babeaf771a51c5b714d234edbf6f6fa1/app/src/main/res/drawable-sw600dp-mdpi/window_background_statusbar_toolbar_tab.9.png -------------------------------------------------------------------------------- /app/src/main/res/drawable-sw600dp-xhdpi-v23/window_background_statusbar_toolbar_tab.9.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CzBiX/v2ex-android/e18b6722babeaf771a51c5b714d234edbf6f6fa1/app/src/main/res/drawable-sw600dp-xhdpi-v23/window_background_statusbar_toolbar_tab.9.png -------------------------------------------------------------------------------- /app/src/main/res/drawable-sw600dp-xhdpi/window_background_statusbar_toolbar_tab.9.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CzBiX/v2ex-android/e18b6722babeaf771a51c5b714d234edbf6f6fa1/app/src/main/res/drawable-sw600dp-xhdpi/window_background_statusbar_toolbar_tab.9.png -------------------------------------------------------------------------------- /app/src/main/res/drawable-sw600dp-xxhdpi-v23/window_background_statusbar_toolbar_tab.9.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CzBiX/v2ex-android/e18b6722babeaf771a51c5b714d234edbf6f6fa1/app/src/main/res/drawable-sw600dp-xxhdpi-v23/window_background_statusbar_toolbar_tab.9.png -------------------------------------------------------------------------------- /app/src/main/res/drawable-sw600dp-xxhdpi/window_background_statusbar_toolbar_tab.9.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CzBiX/v2ex-android/e18b6722babeaf771a51c5b714d234edbf6f6fa1/app/src/main/res/drawable-sw600dp-xxhdpi/window_background_statusbar_toolbar_tab.9.png -------------------------------------------------------------------------------- /app/src/main/res/drawable-sw600dp-xxxhdpi-v23/window_background_statusbar_toolbar_tab.9.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CzBiX/v2ex-android/e18b6722babeaf771a51c5b714d234edbf6f6fa1/app/src/main/res/drawable-sw600dp-xxxhdpi-v23/window_background_statusbar_toolbar_tab.9.png -------------------------------------------------------------------------------- /app/src/main/res/drawable-sw600dp-xxxhdpi/window_background_statusbar_toolbar_tab.9.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CzBiX/v2ex-android/e18b6722babeaf771a51c5b714d234edbf6f6fa1/app/src/main/res/drawable-sw600dp-xxxhdpi/window_background_statusbar_toolbar_tab.9.png -------------------------------------------------------------------------------- /app/src/main/res/drawable-xhdpi-v23/window_background_statusbar_toolbar_tab.9.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CzBiX/v2ex-android/e18b6722babeaf771a51c5b714d234edbf6f6fa1/app/src/main/res/drawable-xhdpi-v23/window_background_statusbar_toolbar_tab.9.png -------------------------------------------------------------------------------- /app/src/main/res/drawable-xhdpi/ic_card_giftcard_white_24dp.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CzBiX/v2ex-android/e18b6722babeaf771a51c5b714d234edbf6f6fa1/app/src/main/res/drawable-xhdpi/ic_card_giftcard_white_24dp.png -------------------------------------------------------------------------------- /app/src/main/res/drawable-xhdpi/ic_favorite_border_white_24dp.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CzBiX/v2ex-android/e18b6722babeaf771a51c5b714d234edbf6f6fa1/app/src/main/res/drawable-xhdpi/ic_favorite_border_white_24dp.png -------------------------------------------------------------------------------- /app/src/main/res/drawable-xhdpi/ic_favorite_white_24dp.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CzBiX/v2ex-android/e18b6722babeaf771a51c5b714d234edbf6f6fa1/app/src/main/res/drawable-xhdpi/ic_favorite_white_24dp.png -------------------------------------------------------------------------------- /app/src/main/res/drawable-xhdpi/ic_keyboard_arrow_down_white_24dp.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CzBiX/v2ex-android/e18b6722babeaf771a51c5b714d234edbf6f6fa1/app/src/main/res/drawable-xhdpi/ic_keyboard_arrow_down_white_24dp.png -------------------------------------------------------------------------------- /app/src/main/res/drawable-xhdpi/ic_notifications_white_24dp.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CzBiX/v2ex-android/e18b6722babeaf771a51c5b714d234edbf6f6fa1/app/src/main/res/drawable-xhdpi/ic_notifications_white_24dp.png -------------------------------------------------------------------------------- /app/src/main/res/drawable-xhdpi/ic_refresh_white_24dp.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CzBiX/v2ex-android/e18b6722babeaf771a51c5b714d234edbf6f6fa1/app/src/main/res/drawable-xhdpi/ic_refresh_white_24dp.png -------------------------------------------------------------------------------- /app/src/main/res/drawable-xhdpi/ic_search_white_24dp.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CzBiX/v2ex-android/e18b6722babeaf771a51c5b714d234edbf6f6fa1/app/src/main/res/drawable-xhdpi/ic_search_white_24dp.png -------------------------------------------------------------------------------- /app/src/main/res/drawable-xhdpi/ic_sync_disabled_white_24dp.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CzBiX/v2ex-android/e18b6722babeaf771a51c5b714d234edbf6f6fa1/app/src/main/res/drawable-xhdpi/ic_sync_disabled_white_24dp.png -------------------------------------------------------------------------------- /app/src/main/res/drawable-xhdpi/ic_sync_problem_white_24dp.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CzBiX/v2ex-android/e18b6722babeaf771a51c5b714d234edbf6f6fa1/app/src/main/res/drawable-xhdpi/ic_sync_problem_white_24dp.png -------------------------------------------------------------------------------- /app/src/main/res/drawable-xhdpi/ic_sync_white_24dp.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CzBiX/v2ex-android/e18b6722babeaf771a51c5b714d234edbf6f6fa1/app/src/main/res/drawable-xhdpi/ic_sync_white_24dp.png -------------------------------------------------------------------------------- /app/src/main/res/drawable-xhdpi/window_background_statusbar_toolbar_tab.9.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CzBiX/v2ex-android/e18b6722babeaf771a51c5b714d234edbf6f6fa1/app/src/main/res/drawable-xhdpi/window_background_statusbar_toolbar_tab.9.png -------------------------------------------------------------------------------- /app/src/main/res/drawable-xxhdpi-v23/window_background_statusbar_toolbar_tab.9.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CzBiX/v2ex-android/e18b6722babeaf771a51c5b714d234edbf6f6fa1/app/src/main/res/drawable-xxhdpi-v23/window_background_statusbar_toolbar_tab.9.png -------------------------------------------------------------------------------- /app/src/main/res/drawable-xxhdpi/ic_card_giftcard_white_24dp.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CzBiX/v2ex-android/e18b6722babeaf771a51c5b714d234edbf6f6fa1/app/src/main/res/drawable-xxhdpi/ic_card_giftcard_white_24dp.png -------------------------------------------------------------------------------- /app/src/main/res/drawable-xxhdpi/ic_favorite_border_white_24dp.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CzBiX/v2ex-android/e18b6722babeaf771a51c5b714d234edbf6f6fa1/app/src/main/res/drawable-xxhdpi/ic_favorite_border_white_24dp.png -------------------------------------------------------------------------------- /app/src/main/res/drawable-xxhdpi/ic_favorite_white_24dp.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CzBiX/v2ex-android/e18b6722babeaf771a51c5b714d234edbf6f6fa1/app/src/main/res/drawable-xxhdpi/ic_favorite_white_24dp.png -------------------------------------------------------------------------------- /app/src/main/res/drawable-xxhdpi/ic_keyboard_arrow_down_white_24dp.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CzBiX/v2ex-android/e18b6722babeaf771a51c5b714d234edbf6f6fa1/app/src/main/res/drawable-xxhdpi/ic_keyboard_arrow_down_white_24dp.png -------------------------------------------------------------------------------- /app/src/main/res/drawable-xxhdpi/ic_notifications_white_24dp.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CzBiX/v2ex-android/e18b6722babeaf771a51c5b714d234edbf6f6fa1/app/src/main/res/drawable-xxhdpi/ic_notifications_white_24dp.png -------------------------------------------------------------------------------- /app/src/main/res/drawable-xxhdpi/ic_refresh_white_24dp.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CzBiX/v2ex-android/e18b6722babeaf771a51c5b714d234edbf6f6fa1/app/src/main/res/drawable-xxhdpi/ic_refresh_white_24dp.png -------------------------------------------------------------------------------- /app/src/main/res/drawable-xxhdpi/ic_search_white_24dp.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CzBiX/v2ex-android/e18b6722babeaf771a51c5b714d234edbf6f6fa1/app/src/main/res/drawable-xxhdpi/ic_search_white_24dp.png -------------------------------------------------------------------------------- /app/src/main/res/drawable-xxhdpi/ic_sync_disabled_white_24dp.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CzBiX/v2ex-android/e18b6722babeaf771a51c5b714d234edbf6f6fa1/app/src/main/res/drawable-xxhdpi/ic_sync_disabled_white_24dp.png -------------------------------------------------------------------------------- /app/src/main/res/drawable-xxhdpi/ic_sync_problem_white_24dp.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CzBiX/v2ex-android/e18b6722babeaf771a51c5b714d234edbf6f6fa1/app/src/main/res/drawable-xxhdpi/ic_sync_problem_white_24dp.png -------------------------------------------------------------------------------- /app/src/main/res/drawable-xxhdpi/ic_sync_white_24dp.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CzBiX/v2ex-android/e18b6722babeaf771a51c5b714d234edbf6f6fa1/app/src/main/res/drawable-xxhdpi/ic_sync_white_24dp.png -------------------------------------------------------------------------------- /app/src/main/res/drawable-xxhdpi/window_background_statusbar_toolbar_tab.9.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CzBiX/v2ex-android/e18b6722babeaf771a51c5b714d234edbf6f6fa1/app/src/main/res/drawable-xxhdpi/window_background_statusbar_toolbar_tab.9.png -------------------------------------------------------------------------------- /app/src/main/res/drawable-xxxhdpi-v23/window_background_statusbar_toolbar_tab.9.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CzBiX/v2ex-android/e18b6722babeaf771a51c5b714d234edbf6f6fa1/app/src/main/res/drawable-xxxhdpi-v23/window_background_statusbar_toolbar_tab.9.png -------------------------------------------------------------------------------- /app/src/main/res/drawable-xxxhdpi/ic_card_giftcard_white_24dp.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CzBiX/v2ex-android/e18b6722babeaf771a51c5b714d234edbf6f6fa1/app/src/main/res/drawable-xxxhdpi/ic_card_giftcard_white_24dp.png -------------------------------------------------------------------------------- /app/src/main/res/drawable-xxxhdpi/ic_favorite_border_white_24dp.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CzBiX/v2ex-android/e18b6722babeaf771a51c5b714d234edbf6f6fa1/app/src/main/res/drawable-xxxhdpi/ic_favorite_border_white_24dp.png -------------------------------------------------------------------------------- /app/src/main/res/drawable-xxxhdpi/ic_favorite_white_24dp.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CzBiX/v2ex-android/e18b6722babeaf771a51c5b714d234edbf6f6fa1/app/src/main/res/drawable-xxxhdpi/ic_favorite_white_24dp.png -------------------------------------------------------------------------------- /app/src/main/res/drawable-xxxhdpi/ic_keyboard_arrow_down_white_24dp.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CzBiX/v2ex-android/e18b6722babeaf771a51c5b714d234edbf6f6fa1/app/src/main/res/drawable-xxxhdpi/ic_keyboard_arrow_down_white_24dp.png -------------------------------------------------------------------------------- /app/src/main/res/drawable-xxxhdpi/ic_notifications_white_24dp.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CzBiX/v2ex-android/e18b6722babeaf771a51c5b714d234edbf6f6fa1/app/src/main/res/drawable-xxxhdpi/ic_notifications_white_24dp.png -------------------------------------------------------------------------------- /app/src/main/res/drawable-xxxhdpi/ic_refresh_white_24dp.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CzBiX/v2ex-android/e18b6722babeaf771a51c5b714d234edbf6f6fa1/app/src/main/res/drawable-xxxhdpi/ic_refresh_white_24dp.png -------------------------------------------------------------------------------- /app/src/main/res/drawable-xxxhdpi/ic_search_white_24dp.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CzBiX/v2ex-android/e18b6722babeaf771a51c5b714d234edbf6f6fa1/app/src/main/res/drawable-xxxhdpi/ic_search_white_24dp.png -------------------------------------------------------------------------------- /app/src/main/res/drawable-xxxhdpi/ic_sync_disabled_white_24dp.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CzBiX/v2ex-android/e18b6722babeaf771a51c5b714d234edbf6f6fa1/app/src/main/res/drawable-xxxhdpi/ic_sync_disabled_white_24dp.png -------------------------------------------------------------------------------- /app/src/main/res/drawable-xxxhdpi/ic_sync_problem_white_24dp.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CzBiX/v2ex-android/e18b6722babeaf771a51c5b714d234edbf6f6fa1/app/src/main/res/drawable-xxxhdpi/ic_sync_problem_white_24dp.png -------------------------------------------------------------------------------- /app/src/main/res/drawable-xxxhdpi/ic_sync_white_24dp.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CzBiX/v2ex-android/e18b6722babeaf771a51c5b714d234edbf6f6fa1/app/src/main/res/drawable-xxxhdpi/ic_sync_white_24dp.png -------------------------------------------------------------------------------- /app/src/main/res/drawable-xxxhdpi/window_background_statusbar_toolbar_tab.9.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CzBiX/v2ex-android/e18b6722babeaf771a51c5b714d234edbf6f6fa1/app/src/main/res/drawable-xxxhdpi/window_background_statusbar_toolbar_tab.9.png -------------------------------------------------------------------------------- /app/src/main/res/drawable/bg_author_flag.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | 10 | 14 | 15 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/btn_jump_back_background.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | 7 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/count_background.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | 11 | 14 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_account_circle.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 10 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_brightness_medium_outline.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 10 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_cancel_outline_black_24dp.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 10 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_comment_black_24dp.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 10 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_comment_outline.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 10 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_content_copy_outline_black_24dp.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 10 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_get_app_black_24dp.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 10 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_image_outline.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 10 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_info_outline_black_24dp.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 10 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_launcher_foreground.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 10 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_person_add_outline.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 10 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_share_black_24dp.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 10 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_star_black_24dp.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 10 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_star_border_black_24dp.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 10 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_undo_black_24dp.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 10 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_update_black_24dp.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 10 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/img_topic_image_loading.xml: -------------------------------------------------------------------------------- 1 | 2 | 7 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/radius_box.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | 9 | 10 | -------------------------------------------------------------------------------- /app/src/main/res/layout/activity_loading.xml: -------------------------------------------------------------------------------- 1 | 9 | -------------------------------------------------------------------------------- /app/src/main/res/layout/activity_main.xml: -------------------------------------------------------------------------------- 1 | 7 | 12 | 13 | 19 | 20 | 21 | 26 | 27 | 28 | 29 | -------------------------------------------------------------------------------- /app/src/main/res/layout/activity_topic.xml: -------------------------------------------------------------------------------- 1 | 2 | 9 | 10 | 16 | 17 | -------------------------------------------------------------------------------- /app/src/main/res/layout/activity_topic_edit.xml: -------------------------------------------------------------------------------- 1 | 11 | 12 | 16 | 24 | 33 | 34 | 35 | -------------------------------------------------------------------------------- /app/src/main/res/layout/app_bar_layout.xml: -------------------------------------------------------------------------------- 1 | 2 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /app/src/main/res/layout/container_fragment.xml: -------------------------------------------------------------------------------- 1 | 2 | 8 | -------------------------------------------------------------------------------- /app/src/main/res/layout/fragment_node_list.xml: -------------------------------------------------------------------------------- 1 | 2 | 7 | 12 | 13 | -------------------------------------------------------------------------------- /app/src/main/res/layout/fragment_notification_list.xml: -------------------------------------------------------------------------------- 1 | 2 | 7 | 11 | 12 | -------------------------------------------------------------------------------- /app/src/main/res/layout/fragment_topic.xml: -------------------------------------------------------------------------------- 1 | 7 | 8 | 16 | 17 | 22 | 23 | 24 | 37 | 38 | 48 | 49 | -------------------------------------------------------------------------------- /app/src/main/res/layout/fragment_topic_list.xml: -------------------------------------------------------------------------------- 1 | 6 | 11 | 12 | -------------------------------------------------------------------------------- /app/src/main/res/layout/model_comments_footer.xml: -------------------------------------------------------------------------------- 1 | 2 | 5 | 6 | 7 | 10 | 13 | 14 | 15 | 18 | 19 | 32 | 33 | 34 | -------------------------------------------------------------------------------- /app/src/main/res/layout/model_postscript.xml: -------------------------------------------------------------------------------- 1 | 2 | 5 | 6 | 7 | 10 | 13 | 16 | 17 | 18 | 25 | 26 | 32 | 33 | 44 | 45 | 55 | 56 | -------------------------------------------------------------------------------- /app/src/main/res/layout/nav_header.xml: -------------------------------------------------------------------------------- 1 | 2 | 12 | 19 | 20 | 28 | 29 | 43 | 44 | -------------------------------------------------------------------------------- /app/src/main/res/layout/nav_view.xml: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /app/src/main/res/layout/recycle_view.xml: -------------------------------------------------------------------------------- 1 | 5 | -------------------------------------------------------------------------------- /app/src/main/res/layout/tab_layout.xml: -------------------------------------------------------------------------------- 1 | 2 | 7 | 12 | 16 | -------------------------------------------------------------------------------- /app/src/main/res/layout/toolbar_action.xml: -------------------------------------------------------------------------------- 1 | 2 | 9 | -------------------------------------------------------------------------------- /app/src/main/res/layout/view_alert_dialog.xml: -------------------------------------------------------------------------------- 1 | 2 | 5 | 10 | 11 | -------------------------------------------------------------------------------- /app/src/main/res/layout/view_comment.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | -------------------------------------------------------------------------------- /app/src/main/res/layout/view_content_image.xml: -------------------------------------------------------------------------------- 1 | 2 | 13 | 14 | -------------------------------------------------------------------------------- /app/src/main/res/layout/view_content_text.xml: -------------------------------------------------------------------------------- 1 | 2 | 16 | -------------------------------------------------------------------------------- /app/src/main/res/layout/view_edittext.xml: -------------------------------------------------------------------------------- 1 | 2 | 7 | 16 | -------------------------------------------------------------------------------- /app/src/main/res/layout/view_float_topic.xml: -------------------------------------------------------------------------------- 1 | 2 | 6 | 7 | 13 | 14 | 19 | 20 | -------------------------------------------------------------------------------- /app/src/main/res/layout/view_node.xml: -------------------------------------------------------------------------------- 1 | 2 | 7 | 11 | 19 | 28 | 34 | 35 | 36 | -------------------------------------------------------------------------------- /app/src/main/res/layout/view_reply_form.xml: -------------------------------------------------------------------------------- 1 | 2 | 9 | 10 | 15 | 16 | 28 | 29 | 40 | 41 | 51 | -------------------------------------------------------------------------------- /app/src/main/res/layout/view_search_box.xml: -------------------------------------------------------------------------------- 1 | 2 | 7 | 8 | 15 | 26 | 27 | 37 | 38 | 50 | 51 | -------------------------------------------------------------------------------- /app/src/main/res/layout/view_select_node.xml: -------------------------------------------------------------------------------- 1 | 2 | 5 | 9 | 10 | 14 | -------------------------------------------------------------------------------- /app/src/main/res/layout/view_topic.xml: -------------------------------------------------------------------------------- 1 | 2 | 6 | 7 | -------------------------------------------------------------------------------- /app/src/main/res/menu/menu_comment.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | 9 | 12 | 15 | -------------------------------------------------------------------------------- /app/src/main/res/menu/menu_drawer.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 10 | 13 | 16 | 19 | 20 | 22 | 26 | 31 | 35 | 36 | -------------------------------------------------------------------------------- /app/src/main/res/menu/menu_main.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 7 | -------------------------------------------------------------------------------- /app/src/main/res/menu/menu_nodes.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 9 | -------------------------------------------------------------------------------- /app/src/main/res/menu/menu_topic.xml: -------------------------------------------------------------------------------- 1 | 2 | 5 | 10 | 15 | 19 | 23 | 25 | 27 | 30 | 33 | -------------------------------------------------------------------------------- /app/src/main/res/menu/menu_topic_edit.xml: -------------------------------------------------------------------------------- 1 | 5 | 9 | 12 | 13 | -------------------------------------------------------------------------------- /app/src/main/res/menu/menu_topic_list.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 8 | 11 | 15 | -------------------------------------------------------------------------------- /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/res/mipmap-hdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CzBiX/v2ex-android/e18b6722babeaf771a51c5b714d234edbf6f6fa1/app/src/main/res/mipmap-hdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-mdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CzBiX/v2ex-android/e18b6722babeaf771a51c5b714d234edbf6f6fa1/app/src/main/res/mipmap-mdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CzBiX/v2ex-android/e18b6722babeaf771a51c5b714d234edbf6f6fa1/app/src/main/res/mipmap-xhdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CzBiX/v2ex-android/e18b6722babeaf771a51c5b714d234edbf6f6fa1/app/src/main/res/mipmap-xxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CzBiX/v2ex-android/e18b6722babeaf771a51c5b714d234edbf6f6fa1/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/values-night/colors.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | #444 4 | -------------------------------------------------------------------------------- /app/src/main/res/values-w820dp/dimens.xml: -------------------------------------------------------------------------------- 1 | 2 | 5 | 64dp 6 | 7 | -------------------------------------------------------------------------------- /app/src/main/res/values-zh/arrays.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 浅色 5 | 深色 6 | 系统 7 | 8 | -------------------------------------------------------------------------------- /app/src/main/res/values/arrays.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Light 5 | Dark 6 | System 7 | 8 | 9 | 10 | light 11 | dark 12 | system 13 | 14 | -------------------------------------------------------------------------------- /app/src/main/res/values/colors.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | #4caf50 4 | #607d8b 5 | #455a64 6 | #263238 7 | #448aff 8 | @color/material_blue_grey_500 9 | #88000000 10 | #eee 11 | @color/placeholder 12 | -------------------------------------------------------------------------------- /app/src/main/res/values/config.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | AEdPqrEAAAAIA-7Ap1EsED8jjzKblgxW7060iEU8t2tCiviIRA 4 | W/"a57d2379cb65e3aee54669369f755513fac12400" 5 | 6 | -------------------------------------------------------------------------------- /app/src/main/res/values/dimens.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 16dp 4 | 16dp 5 | 48dp 6 | 7 | 64dp 8 | 48dp 9 | 48dp 10 | 48dp 11 | 12 | 8dp 13 | 14 | 40dp 15 | 4dp 16 | 8dp 17 | 18 | -------------------------------------------------------------------------------- /app/src/main/res/values/ic_launcher_background.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | #E0E0E0 4 | -------------------------------------------------------------------------------- /app/src/main/res/values/ids.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 100 4 | 5 | -------------------------------------------------------------------------------- /app/src/main/res/values/intent_filter.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | http 4 | https 5 | 6 | v2ex.com 7 | www.v2ex.com 8 | 9 | /t/ 10 | /go/ 11 | /member/ 12 | -------------------------------------------------------------------------------- /app/src/main/res/values/strings_activity_login.xml: -------------------------------------------------------------------------------- 1 | 2 | Sign in 3 | 4 | 5 | Account 6 | Password 7 | Captcha 8 | Load Captcha 9 | Sign in 10 | Sign up 11 | Reset password 12 | V2EX = way to explore\n一个关于分享和探索的地方 13 | Welcome back, %s! 14 | 15 | Two factor auth 16 | This field is required 17 | Invalid auth code 18 | Load Captcha failed! 19 | Sign in failed! 20 | Google Sign In 21 | 22 | -------------------------------------------------------------------------------- /app/src/main/res/values/strings_activity_settings.xml: -------------------------------------------------------------------------------- 1 | 2 | Settings 3 | General 4 | User info 5 | Log out 6 | Sign in 7 | User 8 | Customize tabs to show 9 | Long press to sort 10 | Receive notifications 11 | Receive notifications via FCM service 12 | Google Play services not available,\n error code: %s 13 | Always show reply form 14 | 2.5D Touch (Experimental) 15 | Quick preview topic by deeply press 16 | Content be selectable 17 | May cause crash on some devices 18 | Enable undo 19 | Delay before send request, so you may cancel it. 20 | Version 21 | 22 | -------------------------------------------------------------------------------- /app/src/main/res/values/styles.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 7 | 8 | 12 | 13 | 16 | 17 | 24 | 25 | 28 | 29 | 33 | 34 | 38 | 39 | 43 | 44 | 49 | 50 | 53 | 54 | 58 | 59 | 64 | 65 | -------------------------------------------------------------------------------- /app/src/test/kotlin/com/czbix/v2ex/util/MiscUtilsTest.kt: -------------------------------------------------------------------------------- 1 | package com.czbix.v2ex.util 2 | 3 | import io.kotlintest.data.forall 4 | import io.kotlintest.shouldBe 5 | import io.kotlintest.specs.FunSpec 6 | import io.kotlintest.tables.row 7 | 8 | class MiscUtilsTest : FunSpec({ 9 | test("decode cf email") { 10 | forall( 11 | row("99aeaba9e9d9aeafa1a9ffe9ea", "720p@7680fps"), 12 | row("19282921296959202f297f696a", "1080p@960fps") 13 | ) { encoded, decoded -> 14 | MiscUtils.decodeCfEmail(encoded) shouldBe decoded 15 | } 16 | } 17 | }) -------------------------------------------------------------------------------- /build.gradle: -------------------------------------------------------------------------------- 1 | // Top-level build file where you can add configuration options common to all sub-projects/modules. 2 | 3 | buildscript { 4 | repositories { 5 | google() 6 | mavenCentral() 7 | } 8 | dependencies { 9 | classpath 'com.android.tools.build:gradle:8.3.0' 10 | 11 | // NOTE: Do not place your application dependencies here; they belong 12 | // in the individual module build.gradle files 13 | } 14 | } 15 | 16 | allprojects { 17 | buildscript { 18 | repositories { 19 | google() 20 | mavenCentral() 21 | } 22 | } 23 | 24 | repositories { 25 | google() 26 | mavenCentral() 27 | } 28 | } 29 | 30 | task clean(type: Delete) { 31 | delete rootProject.buildDir 32 | } 33 | -------------------------------------------------------------------------------- /gradle.properties: -------------------------------------------------------------------------------- 1 | # Project-wide Gradle settings. 2 | 3 | # IDE (e.g. Android Studio) users: 4 | # Gradle settings configured through the IDE *will override* 5 | # any settings specified in this file. 6 | 7 | # For more details on how to configure your build environment visit 8 | # http://www.gradle.org/docs/current/userguide/build_environment.html 9 | 10 | # Specifies the JVM arguments used for the daemon process. 11 | # The setting is particularly useful for tweaking memory settings. 12 | # Default value: -Xmx10248m -XX:MaxPermSize=256m 13 | # org.gradle.jvmargs=-Xmx2048m -XX:MaxPermSize=512m -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8 14 | 15 | # When configured, Gradle will run in incubating parallel mode. 16 | # This option should only be used with decoupled projects. More details, visit 17 | # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects 18 | # org.gradle.parallel=true 19 | android.enableJetifier=true 20 | android.nonFinalResIds=false 21 | android.nonTransitiveRClass=false 22 | android.useAndroidX=true 23 | org.gradle.jvmargs=-Xmx2g 24 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CzBiX/v2ex-android/e18b6722babeaf771a51c5b714d234edbf6f6fa1/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | distributionBase=GRADLE_USER_HOME 2 | distributionPath=wrapper/dists 3 | distributionUrl=https\://services.gradle.org/distributions/gradle-8.4-bin.zip 4 | zipStoreBase=GRADLE_USER_HOME 5 | zipStorePath=wrapper/dists 6 | -------------------------------------------------------------------------------- /gradlew.bat: -------------------------------------------------------------------------------- 1 | @rem 2 | @rem Copyright 2015 the original author or authors. 3 | @rem 4 | @rem Licensed under the Apache License, Version 2.0 (the "License"); 5 | @rem you may not use this file except in compliance with the License. 6 | @rem You may obtain a copy of the License at 7 | @rem 8 | @rem https://www.apache.org/licenses/LICENSE-2.0 9 | @rem 10 | @rem Unless required by applicable law or agreed to in writing, software 11 | @rem distributed under the License is distributed on an "AS IS" BASIS, 12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | @rem See the License for the specific language governing permissions and 14 | @rem limitations under the License. 15 | @rem 16 | 17 | @if "%DEBUG%" == "" @echo off 18 | @rem ########################################################################## 19 | @rem 20 | @rem Gradle startup script for Windows 21 | @rem 22 | @rem ########################################################################## 23 | 24 | @rem Set local scope for the variables with windows NT shell 25 | if "%OS%"=="Windows_NT" setlocal 26 | 27 | set DIRNAME=%~dp0 28 | if "%DIRNAME%" == "" set DIRNAME=. 29 | set APP_BASE_NAME=%~n0 30 | set APP_HOME=%DIRNAME% 31 | 32 | @rem Resolve any "." and ".." in APP_HOME to make it shorter. 33 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi 34 | 35 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 36 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" 37 | 38 | @rem Find java.exe 39 | if defined JAVA_HOME goto findJavaFromJavaHome 40 | 41 | set JAVA_EXE=java.exe 42 | %JAVA_EXE% -version >NUL 2>&1 43 | if "%ERRORLEVEL%" == "0" goto execute 44 | 45 | echo. 46 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 47 | echo. 48 | echo Please set the JAVA_HOME variable in your environment to match the 49 | echo location of your Java installation. 50 | 51 | goto fail 52 | 53 | :findJavaFromJavaHome 54 | set JAVA_HOME=%JAVA_HOME:"=% 55 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 56 | 57 | if exist "%JAVA_EXE%" goto execute 58 | 59 | echo. 60 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 61 | echo. 62 | echo Please set the JAVA_HOME variable in your environment to match the 63 | echo location of your Java installation. 64 | 65 | goto fail 66 | 67 | :execute 68 | @rem Setup the command line 69 | 70 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 71 | 72 | 73 | @rem Execute Gradle 74 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* 75 | 76 | :end 77 | @rem End local scope for the variables with windows NT shell 78 | if "%ERRORLEVEL%"=="0" goto mainEnd 79 | 80 | :fail 81 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 82 | rem the _cmd.exe /c_ return code! 83 | if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 84 | exit /b 1 85 | 86 | :mainEnd 87 | if "%OS%"=="Windows_NT" endlocal 88 | 89 | :omega 90 | -------------------------------------------------------------------------------- /settings.gradle: -------------------------------------------------------------------------------- 1 | include ':app' 2 | --------------------------------------------------------------------------------