From a319baa7365ff2d8ef8b54b92a3727f0161b88c8 Mon Sep 17 00:00:00 2001 From: Allan Wang Date: Thu, 15 Sep 2022 15:17:07 -0700 Subject: [PATCH] Apply ktfmt --- .editorconfig | 95 ++ .../com/pitchedapps/frost/FrostTestApp.kt | 14 +- .../pitchedapps/frost/StartActivityTest.kt | 26 +- .../com/pitchedapps/frost/TestModules.kt | 8 +- .../frost/activities/AboutActivityTest.kt | 20 +- .../frost/activities/DebugActivityTest.kt | 20 +- .../frost/activities/FrostWebActivityTest.kt | 26 +- .../frost/activities/ImageActivityTest.kt | 242 ++- .../frost/activities/IntroActivityTest.kt | 20 +- .../frost/activities/LoginActivityTest.kt | 20 +- .../frost/activities/MainActivityTest.kt | 20 +- .../frost/activities/SelectorActivityTest.kt | 20 +- .../frost/activities/SettingActivityTest.kt | 20 +- .../activities/TabCustomizerActivityTest.kt | 20 +- .../activities/WebOverlayActivityTest.kt | 20 +- .../com/pitchedapps/frost/db/BaseDbTest.kt | 30 +- .../com/pitchedapps/frost/db/CacheDbTest.kt | 40 +- .../com/pitchedapps/frost/db/CookieDbTest.kt | 103 +- .../frost/db/CookieMigrationTest.kt | 50 +- .../com/pitchedapps/frost/db/GenericDbTest.kt | 86 +- .../frost/db/NotificationDbTest.kt | 221 ++- .../frost/facebook/FbCookieTest.kt | 16 +- .../com/pitchedapps/frost/helper/Helper.kt | 16 +- .../kotlin/com/pitchedapps/frost/FrostApp.kt | 101 +- .../com/pitchedapps/frost/StartActivity.kt | 129 +- .../frost/activities/AboutActivity.kt | 266 ++- .../frost/activities/BaseActivity.kt | 58 +- .../frost/activities/BaseMainActivity.kt | 1455 ++++++++--------- .../frost/activities/DebugActivity.kt | 163 +- .../frost/activities/ImageActivity.kt | 548 +++---- .../frost/activities/IntroActivity.kt | 290 ++-- .../frost/activities/LoginActivity.kt | 263 ++- .../frost/activities/MainActivity.kt | 165 +- .../frost/activities/SelectorActivity.kt | 65 +- .../frost/activities/SettingsActivity.kt | 359 ++-- .../frost/activities/TabCustomizerActivity.kt | 179 +- .../frost/activities/WebOverlayActivity.kt | 385 +++-- .../frost/contracts/ActivityContract.kt | 24 +- .../frost/contracts/DynamicUiContract.kt | 29 +- .../frost/contracts/FileChooser.kt | 92 +- .../frost/contracts/FrostContentContract.kt | 187 +-- .../frost/contracts/FrostThemable.kt | 26 +- .../frost/contracts/VideoViewHolder.kt | 49 +- .../com/pitchedapps/frost/db/CacheDb.kt | 73 +- .../com/pitchedapps/frost/db/CookiesDb.kt | 70 +- .../com/pitchedapps/frost/db/DaoUtils.kt | 7 +- .../com/pitchedapps/frost/db/Database.kt | 130 +- .../com/pitchedapps/frost/db/GenericDb.kt | 42 +- .../pitchedapps/frost/db/NotificationDb.kt | 177 +- .../frost/debugger/OfflineWebsite.kt | 500 +++--- .../com/pitchedapps/frost/enums/FeedSort.kt | 18 +- .../frost/enums/MainActivityLayout.kt | 32 +- .../pitchedapps/frost/enums/OverlayContext.kt | 62 +- .../com/pitchedapps/frost/enums/Theme.kt | 150 +- .../com/pitchedapps/frost/facebook/FbConst.kt | 21 +- .../pitchedapps/frost/facebook/FbCookie.kt | 218 ++- .../com/pitchedapps/frost/facebook/FbItem.kt | 124 +- .../com/pitchedapps/frost/facebook/FbRegex.kt | 11 +- .../frost/facebook/FbUrlFormatter.kt | 261 +-- .../frost/facebook/parsers/BadgeParser.kt | 60 +- .../frost/facebook/parsers/FrostParser.kt | 141 +- .../frost/facebook/parsers/MessageParser.kt | 188 +-- .../frost/facebook/parsers/NotifParser.kt | 154 +- .../frost/facebook/parsers/SearchParser.kt | 123 +- .../frost/facebook/requests/FbRequest.kt | 21 +- .../frost/facebook/requests/Images.kt | 30 +- .../frost/fragments/BaseFragment.kt | 326 ++-- .../frost/fragments/FragmentContract.kt | 103 +- .../frost/fragments/RecyclerFragmentBase.kt | 170 +- .../frost/fragments/RecyclerFragments.kt | 21 +- .../frost/fragments/WebFragments.kt | 42 +- .../com/pitchedapps/frost/glide/GlideUtils.kt | 53 +- .../pitchedapps/frost/iitems/GenericIItems.kt | 131 +- .../frost/iitems/NotificationIItem.kt | 207 ++- .../com/pitchedapps/frost/iitems/TabIItem.kt | 54 +- .../pitchedapps/frost/injectors/CssAsset.kt | 31 +- .../pitchedapps/frost/injectors/CssHider.kt | 63 +- .../pitchedapps/frost/injectors/JsActions.kt | 35 +- .../pitchedapps/frost/injectors/JsAssets.kt | 60 +- .../pitchedapps/frost/injectors/JsInjector.kt | 154 +- .../frost/injectors/ThemeProvider.kt | 234 ++- .../frost/intro/IntroFragmentTheme.kt | 74 +- .../frost/intro/IntroImageFragments.kt | 224 +-- .../frost/intro/IntroMainFragments.kt | 170 +- .../com/pitchedapps/frost/prefs/OldPrefs.kt | 110 +- .../com/pitchedapps/frost/prefs/Prefs.kt | 112 +- .../frost/prefs/sections/BehaviourPrefs.kt | 94 +- .../frost/prefs/sections/CorePrefs.kt | 85 +- .../frost/prefs/sections/FeedPrefs.kt | 83 +- .../frost/prefs/sections/NotifPrefs.kt | 96 +- .../frost/prefs/sections/ShowcasePrefs.kt | 17 +- .../frost/prefs/sections/ThemePrefs.kt | 61 +- .../frost/services/BaseJobService.kt | 34 +- .../frost/services/FrostNotifications.kt | 462 +++--- .../frost/services/NotificationService.kt | 184 +-- .../frost/services/NotificationUtils.kt | 183 +-- .../frost/services/UpdateReceiver.kt | 13 +- .../pitchedapps/frost/settings/Appearance.kt | 305 ++-- .../pitchedapps/frost/settings/Behaviour.kt | 134 +- .../com/pitchedapps/frost/settings/Debug.kt | 169 +- .../frost/settings/Experimental.kt | 46 +- .../com/pitchedapps/frost/settings/Feed.kt | 190 +-- .../com/pitchedapps/frost/settings/Network.kt | 19 +- .../frost/settings/Notifications.kt | 309 ++-- .../pitchedapps/frost/settings/Security.kt | 44 +- .../com/pitchedapps/frost/utils/AdBlocker.kt | 51 +- .../frost/utils/AnimatedVectorDelegate.kt | 108 +- .../pitchedapps/frost/utils/BiometricUtils.kt | 171 +- .../com/pitchedapps/frost/utils/BuildUtils.kt | 30 +- .../com/pitchedapps/frost/utils/Const.kt | 4 +- .../com/pitchedapps/frost/utils/Downloader.kt | 102 +- .../com/pitchedapps/frost/utils/EnumUtils.kt | 46 +- .../pitchedapps/frost/utils/JsoupCleaner.kt | 33 +- .../kotlin/com/pitchedapps/frost/utils/L.kt | 38 +- .../com/pitchedapps/frost/utils/TimeUtils.kt | 45 +- .../com/pitchedapps/frost/utils/Utils.kt | 475 +++--- .../pitchedapps/frost/utils/WebContextMenu.kt | 81 +- .../pitchedapps/frost/views/AccountItem.kt | 115 +- .../com/pitchedapps/frost/views/BadgedIcon.kt | 102 +- .../com/pitchedapps/frost/views/DragFrame.kt | 113 +- .../frost/views/FrostContentView.kt | 368 ++--- .../frost/views/FrostRecyclerView.kt | 153 +- .../pitchedapps/frost/views/FrostVideoView.kt | 556 +++---- .../frost/views/FrostVideoViewer.kt | 327 ++-- .../pitchedapps/frost/views/FrostViewPager.kt | 37 +- .../pitchedapps/frost/views/FrostWebView.kt | 334 ++-- .../frost/views/KPrefTextSeekbar.kt | 59 +- .../com/pitchedapps/frost/views/Keywords.kt | 161 +- .../frost/views/SwipeRefreshLayout.kt | 122 +- .../com/pitchedapps/frost/web/DebugWebView.kt | 142 +- .../frost/web/FrostChromeClients.kt | 211 ++- .../com/pitchedapps/frost/web/FrostJSI.kt | 232 ++- .../frost/web/FrostRequestInterceptor.kt | 55 +- .../frost/web/FrostUrlOverlayValidator.kt | 123 +- .../com/pitchedapps/frost/web/FrostWeb.kt | 46 +- .../frost/web/FrostWebViewClients.kt | 486 +++--- .../frost/web/FrostWebViewClients2.kt | 224 ++- .../com/pitchedapps/frost/web/LoginWebView.kt | 153 +- .../pitchedapps/frost/web/NestedWebView.kt | 189 ++- .../frost/widgets/NotificationWidget.kt | 259 ++- .../frost/debugger/OfflineWebsiteTest.kt | 291 ++-- .../pitchedapps/frost/facebook/FbConstTest.kt | 48 +- .../pitchedapps/frost/facebook/FbDomTest.kt | 26 +- .../pitchedapps/frost/facebook/FbRegexTest.kt | 109 +- .../pitchedapps/frost/facebook/FbUrlTest.kt | 276 ++-- .../frost/facebook/parsers/FbParseTest.kt | 113 +- .../facebook/requests/FbFullImageTest.kt | 34 +- .../frost/injectors/InjectorTest.kt | 12 +- .../frost/injectors/JsAssetsTest.kt | 12 +- .../frost/injectors/TagObfuscatorTest.kt | 25 +- .../frost/injectors/ThemeProviderTest.kt | 16 +- .../pitchedapps/frost/internal/Internal.kt | 71 +- .../com/pitchedapps/frost/prefs/PrefsTest.kt | 60 +- .../pitchedapps/frost/utils/BuildUtilsTest.kt | 28 +- .../pitchedapps/frost/utils/CoroutineTest.kt | 72 +- .../frost/utils/JsoupCleanerTest.kt | 82 +- .../frost/utils/StringEscapeUtilsTest.kt | 16 +- .../com/pitchedapps/frost/utils/UrlTests.kt | 68 +- buildSrc/src/main/kotlin/Versions.kt | 48 +- spotless.gradle | 2 +- 160 files changed, 9910 insertions(+), 10751 deletions(-) create mode 100644 .editorconfig diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 000000000..46efc6101 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,95 @@ +# https://raw.githubusercontent.com/facebookincubator/ktfmt/main/docs/editorconfig/.editorconfig-google +# +# This .editorconfig section approximates ktfmt's formatting rules. You can include it in an +# existing .editorconfig file or use it standalone by copying it to /.editorconfig +# and making sure your editor is set to read settings from .editorconfig files. +# +# It includes editor-specific config options for IntelliJ IDEA. +# +# If any option is wrong, PR are welcome + +[{*.kt,*.kts}] +indent_style = space +insert_final_newline = true +max_line_length = 100 +indent_size = 2 +ij_continuation_indent_size = 2 +ij_java_names_count_to_use_import_on_demand = 9999 +ij_kotlin_align_in_columns_case_branch = false +ij_kotlin_align_multiline_binary_operation = false +ij_kotlin_align_multiline_extends_list = false +ij_kotlin_align_multiline_method_parentheses = false +ij_kotlin_align_multiline_parameters = true +ij_kotlin_align_multiline_parameters_in_calls = false +ij_kotlin_allow_trailing_comma = true +ij_kotlin_allow_trailing_comma_on_call_site = true +ij_kotlin_assignment_wrap = normal +ij_kotlin_blank_lines_after_class_header = 0 +ij_kotlin_blank_lines_around_block_when_branches = 0 +ij_kotlin_blank_lines_before_declaration_with_comment_or_annotation_on_separate_line = 1 +ij_kotlin_block_comment_at_first_column = true +ij_kotlin_call_parameters_new_line_after_left_paren = true +ij_kotlin_call_parameters_right_paren_on_new_line = false +ij_kotlin_call_parameters_wrap = on_every_item +ij_kotlin_catch_on_new_line = false +ij_kotlin_class_annotation_wrap = split_into_lines +ij_kotlin_code_style_defaults = KOTLIN_OFFICIAL +ij_kotlin_continuation_indent_for_chained_calls = true +ij_kotlin_continuation_indent_for_expression_bodies = true +ij_kotlin_continuation_indent_in_argument_lists = true +ij_kotlin_continuation_indent_in_elvis = false +ij_kotlin_continuation_indent_in_if_conditions = false +ij_kotlin_continuation_indent_in_parameter_lists = false +ij_kotlin_continuation_indent_in_supertype_lists = false +ij_kotlin_else_on_new_line = false +ij_kotlin_enum_constants_wrap = off +ij_kotlin_extends_list_wrap = normal +ij_kotlin_field_annotation_wrap = split_into_lines +ij_kotlin_finally_on_new_line = false +ij_kotlin_if_rparen_on_new_line = false +ij_kotlin_import_nested_classes = false +ij_kotlin_insert_whitespaces_in_simple_one_line_method = true +ij_kotlin_keep_blank_lines_before_right_brace = 2 +ij_kotlin_keep_blank_lines_in_code = 2 +ij_kotlin_keep_blank_lines_in_declarations = 2 +ij_kotlin_keep_first_column_comment = true +ij_kotlin_keep_indents_on_empty_lines = false +ij_kotlin_keep_line_breaks = true +ij_kotlin_lbrace_on_next_line = false +ij_kotlin_line_comment_add_space = false +ij_kotlin_line_comment_at_first_column = true +ij_kotlin_method_annotation_wrap = split_into_lines +ij_kotlin_method_call_chain_wrap = normal +ij_kotlin_method_parameters_new_line_after_left_paren = true +ij_kotlin_method_parameters_right_paren_on_new_line = true +ij_kotlin_method_parameters_wrap = on_every_item +ij_kotlin_name_count_to_use_star_import = 9999 +ij_kotlin_name_count_to_use_star_import_for_members = 9999 +ij_kotlin_parameter_annotation_wrap = off +ij_kotlin_space_after_comma = true +ij_kotlin_space_after_extend_colon = true +ij_kotlin_space_after_type_colon = true +ij_kotlin_space_before_catch_parentheses = true +ij_kotlin_space_before_comma = false +ij_kotlin_space_before_extend_colon = true +ij_kotlin_space_before_for_parentheses = true +ij_kotlin_space_before_if_parentheses = true +ij_kotlin_space_before_lambda_arrow = true +ij_kotlin_space_before_type_colon = false +ij_kotlin_space_before_when_parentheses = true +ij_kotlin_space_before_while_parentheses = true +ij_kotlin_spaces_around_additive_operators = true +ij_kotlin_spaces_around_assignment_operators = true +ij_kotlin_spaces_around_equality_operators = true +ij_kotlin_spaces_around_function_type_arrow = true +ij_kotlin_spaces_around_logical_operators = true +ij_kotlin_spaces_around_multiplicative_operators = true +ij_kotlin_spaces_around_range = false +ij_kotlin_spaces_around_relational_operators = true +ij_kotlin_spaces_around_unary_operator = false +ij_kotlin_spaces_around_when_arrow = true +ij_kotlin_variable_annotation_wrap = off +ij_kotlin_while_on_new_line = false +ij_kotlin_wrap_elvis_expressions = 1 +ij_kotlin_wrap_expression_body_functions = 1 +ij_kotlin_wrap_first_method_in_call_chain = false \ No newline at end of file diff --git a/app/src/androidTest/kotlin/com/pitchedapps/frost/FrostTestApp.kt b/app/src/androidTest/kotlin/com/pitchedapps/frost/FrostTestApp.kt index 021e9c1f7..1d130ebcd 100644 --- a/app/src/androidTest/kotlin/com/pitchedapps/frost/FrostTestApp.kt +++ b/app/src/androidTest/kotlin/com/pitchedapps/frost/FrostTestApp.kt @@ -22,11 +22,11 @@ import androidx.test.runner.AndroidJUnitRunner import dagger.hilt.android.testing.HiltTestApplication class FrostTestRunner : AndroidJUnitRunner() { - override fun newApplication( - cl: ClassLoader?, - className: String?, - context: Context? - ): Application { - return super.newApplication(cl, HiltTestApplication::class.java.name, context) - } + override fun newApplication( + cl: ClassLoader?, + className: String?, + context: Context? + ): Application { + return super.newApplication(cl, HiltTestApplication::class.java.name, context) + } } diff --git a/app/src/androidTest/kotlin/com/pitchedapps/frost/StartActivityTest.kt b/app/src/androidTest/kotlin/com/pitchedapps/frost/StartActivityTest.kt index cf266dd6f..ba3a8706a 100644 --- a/app/src/androidTest/kotlin/com/pitchedapps/frost/StartActivityTest.kt +++ b/app/src/androidTest/kotlin/com/pitchedapps/frost/StartActivityTest.kt @@ -27,22 +27,18 @@ import org.junit.Test @HiltAndroidTest class StartActivityTest { - @get:Rule(order = 0) - val hildAndroidRule = HiltAndroidRule(this) + @get:Rule(order = 0) val hildAndroidRule = HiltAndroidRule(this) - @get:Rule(order = 1) - val activityRule = activityRule( - intentAction = { - putExtra(ARG_URL, TEST_FORMATTED_URL) - } - ) + @get:Rule(order = 1) + val activityRule = + activityRule(intentAction = { putExtra(ARG_URL, TEST_FORMATTED_URL) }) - @Test - fun initializesSuccessfully() { - activityRule.scenario.use { - it.onActivity { - // Verify no crash - } - } + @Test + fun initializesSuccessfully() { + activityRule.scenario.use { + it.onActivity { + // Verify no crash + } } + } } diff --git a/app/src/androidTest/kotlin/com/pitchedapps/frost/TestModules.kt b/app/src/androidTest/kotlin/com/pitchedapps/frost/TestModules.kt index 176db8118..fcbaa67a8 100644 --- a/app/src/androidTest/kotlin/com/pitchedapps/frost/TestModules.kt +++ b/app/src/androidTest/kotlin/com/pitchedapps/frost/TestModules.kt @@ -25,11 +25,7 @@ import dagger.hilt.components.SingletonComponent import dagger.hilt.testing.TestInstallIn @Module -@TestInstallIn( - components = [SingletonComponent::class], - replaces = [PrefFactoryModule::class] -) +@TestInstallIn(components = [SingletonComponent::class], replaces = [PrefFactoryModule::class]) object PrefFactoryTestModule { - @Provides - fun factory(): KPrefFactory = KPrefFactoryInMemory + @Provides fun factory(): KPrefFactory = KPrefFactoryInMemory } diff --git a/app/src/androidTest/kotlin/com/pitchedapps/frost/activities/AboutActivityTest.kt b/app/src/androidTest/kotlin/com/pitchedapps/frost/activities/AboutActivityTest.kt index 6435f22b6..8242eb4ef 100644 --- a/app/src/androidTest/kotlin/com/pitchedapps/frost/activities/AboutActivityTest.kt +++ b/app/src/androidTest/kotlin/com/pitchedapps/frost/activities/AboutActivityTest.kt @@ -25,18 +25,16 @@ import org.junit.Test @HiltAndroidTest class AboutActivityTest { - @get:Rule(order = 0) - val hildAndroidRule = HiltAndroidRule(this) + @get:Rule(order = 0) val hildAndroidRule = HiltAndroidRule(this) - @get:Rule(order = 1) - val activityRule = activityRule() + @get:Rule(order = 1) val activityRule = activityRule() - @Test - fun initializesSuccessfully() { - activityRule.scenario.use { - it.onActivity { - // Verify no crash - } - } + @Test + fun initializesSuccessfully() { + activityRule.scenario.use { + it.onActivity { + // Verify no crash + } } + } } diff --git a/app/src/androidTest/kotlin/com/pitchedapps/frost/activities/DebugActivityTest.kt b/app/src/androidTest/kotlin/com/pitchedapps/frost/activities/DebugActivityTest.kt index 7dd32ada4..9f94a6c08 100644 --- a/app/src/androidTest/kotlin/com/pitchedapps/frost/activities/DebugActivityTest.kt +++ b/app/src/androidTest/kotlin/com/pitchedapps/frost/activities/DebugActivityTest.kt @@ -25,18 +25,16 @@ import org.junit.Test @HiltAndroidTest class DebugActivityTest { - @get:Rule(order = 0) - val hildAndroidRule = HiltAndroidRule(this) + @get:Rule(order = 0) val hildAndroidRule = HiltAndroidRule(this) - @get:Rule(order = 1) - val activityRule = activityRule() + @get:Rule(order = 1) val activityRule = activityRule() - @Test - fun initializesSuccessfully() { - activityRule.scenario.use { - it.onActivity { - // Verify no crash - } - } + @Test + fun initializesSuccessfully() { + activityRule.scenario.use { + it.onActivity { + // Verify no crash + } } + } } diff --git a/app/src/androidTest/kotlin/com/pitchedapps/frost/activities/FrostWebActivityTest.kt b/app/src/androidTest/kotlin/com/pitchedapps/frost/activities/FrostWebActivityTest.kt index d45848008..9f964cd0f 100644 --- a/app/src/androidTest/kotlin/com/pitchedapps/frost/activities/FrostWebActivityTest.kt +++ b/app/src/androidTest/kotlin/com/pitchedapps/frost/activities/FrostWebActivityTest.kt @@ -27,22 +27,18 @@ import org.junit.Test @HiltAndroidTest class FrostWebActivityTest { - @get:Rule(order = 0) - val hildAndroidRule = HiltAndroidRule(this) + @get:Rule(order = 0) val hildAndroidRule = HiltAndroidRule(this) - @get:Rule(order = 1) - val activityRule = activityRule( - intentAction = { - putExtra(ARG_URL, TEST_FORMATTED_URL) - } - ) + @get:Rule(order = 1) + val activityRule = + activityRule(intentAction = { putExtra(ARG_URL, TEST_FORMATTED_URL) }) - @Test - fun initializesSuccessfully() { - activityRule.scenario.use { - it.onActivity { - // Verify no crash - } - } + @Test + fun initializesSuccessfully() { + activityRule.scenario.use { + it.onActivity { + // Verify no crash + } } + } } diff --git a/app/src/androidTest/kotlin/com/pitchedapps/frost/activities/ImageActivityTest.kt b/app/src/androidTest/kotlin/com/pitchedapps/frost/activities/ImageActivityTest.kt index 6c4fe60a8..da5e2ce4b 100644 --- a/app/src/androidTest/kotlin/com/pitchedapps/frost/activities/ImageActivityTest.kt +++ b/app/src/androidTest/kotlin/com/pitchedapps/frost/activities/ImageActivityTest.kt @@ -29,6 +29,11 @@ import com.pitchedapps.frost.utils.ARG_TEXT import com.pitchedapps.frost.utils.isIndirectImageUrl import dagger.hilt.android.testing.HiltAndroidRule import dagger.hilt.android.testing.HiltAndroidTest +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue import okhttp3.internal.closeQuietly import okhttp3.mockwebserver.Dispatcher import okhttp3.mockwebserver.MockResponse @@ -42,140 +47,125 @@ import org.junit.Ignore import org.junit.Rule import org.junit.Test import org.junit.rules.Timeout -import kotlin.test.assertEquals -import kotlin.test.assertFalse -import kotlin.test.assertNotNull -import kotlin.test.assertNull -import kotlin.test.assertTrue @HiltAndroidTest class ImageActivityTest { - @get:Rule(order = 0) - val hildAndroidRule = HiltAndroidRule(this) + @get:Rule(order = 0) val hildAndroidRule = HiltAndroidRule(this) - @get:Rule(order = 1) - val activityRule = activityRule( - intentAction = { - putExtra(ARG_IMAGE_URL, TEST_FORMATTED_URL) - } + @get:Rule(order = 1) + val activityRule = + activityRule(intentAction = { putExtra(ARG_IMAGE_URL, TEST_FORMATTED_URL) }) + + @get:Rule(order = 2) val globalTimeout: Timeout = Timeout.seconds(15) + + lateinit var mockServer: MockWebServer + + @Before + fun before() { + mockServer = mockServer() + } + + @After + fun after() { + mockServer.closeQuietly() + } + + @Test + fun initializesSuccessfully() = + launchScenario(mockServer.url("image").toString()) { + // Verify no crash + } + + @Test + fun validImageTest() = + launchScenario(mockServer.url("image").toString()) { + mockServer.takeRequest() + assertEquals(1, mockServer.requestCount, "One http request expected") + // assertEquals( + // FabStates.DOWNLOAD, + // fabAction, + // "Image should be successful, image should be downloaded" + // ) + assertFalse(binding.error.isVisible, "Error should not be shown") + val tempFile = assertNotNull(tempFile, "Temp file not created") + assertTrue(tempFile.exists(), "Image should be located at temp file") + assertTrue( + System.currentTimeMillis() - tempFile.lastModified() < 2000L, + "Image should have been modified within the last few seconds" + ) + assertNull(errorRef, "No error should exist") + tempFile.delete() + } + + @Test + @Ignore("apparently this fails") + fun invalidImageTest() = + launchScenario(mockServer.url("text").toString()) { + mockServer.takeRequest() + assertEquals(1, mockServer.requestCount, "One http request expected") + assertTrue(binding.error.isVisible, "Error should be shown") + + // assertEquals( + // FabStates.ERROR, + // fabAction, + // "Text should not be a valid image format, error state expected" + // ) + assertEquals("Image format not supported", errorRef?.message, "Error message mismatch") + assertFalse(tempFile?.exists() == true, "Temp file should have been removed") + } + + @Test + fun errorTest() = + launchScenario(mockServer.url("error").toString()) { + mockServer.takeRequest() + assertEquals(1, mockServer.requestCount, "One http request expected") + assertTrue(binding.error.isVisible, "Error should be shown") + // assertEquals(FabStates.ERROR, fabAction, "Error response code, error state + // expected") + assertEquals( + "Unsuccessful response for image: Error mock response", + errorRef?.message, + "Error message mismatch" + ) + assertFalse(tempFile?.exists() == true, "Temp file should have been removed") + } + + private fun launchScenario( + imageUrl: String, + text: String? = null, + cookie: String? = null, + action: ImageActivity.() -> Unit + ) { + assertFalse( + imageUrl.isIndirectImageUrl, + "For simplicity, urls that are direct will be used without modifications in the production code." ) + val intent = + Intent(ApplicationProvider.getApplicationContext(), ImageActivity::class.java).apply { + putExtra(ARG_IMAGE_URL, imageUrl) + putExtra(ARG_TEXT, text) + putExtra(ARG_COOKIE, cookie) + } + ActivityScenario.launch(intent).use { it.onActivity(action) } + } - @get:Rule(order = 2) - val globalTimeout: Timeout = Timeout.seconds(15) - - lateinit var mockServer: MockWebServer - - @Before - fun before() { - mockServer = mockServer() - } - - @After - fun after() { - mockServer.closeQuietly() - } - - @Test - fun initializesSuccessfully() = launchScenario(mockServer.url("image").toString()) { - // Verify no crash - } - - @Test - fun validImageTest() = launchScenario(mockServer.url("image").toString()) { - mockServer.takeRequest() - assertEquals(1, mockServer.requestCount, "One http request expected") -// assertEquals( -// FabStates.DOWNLOAD, -// fabAction, -// "Image should be successful, image should be downloaded" -// ) - assertFalse(binding.error.isVisible, "Error should not be shown") - val tempFile = assertNotNull(tempFile, "Temp file not created") - assertTrue(tempFile.exists(), "Image should be located at temp file") - assertTrue( - System.currentTimeMillis() - tempFile.lastModified() < 2000L, - "Image should have been modified within the last few seconds" - ) - assertNull(errorRef, "No error should exist") - tempFile.delete() - } - - @Test - @Ignore("apparently this fails") - fun invalidImageTest() = launchScenario(mockServer.url("text").toString()) { - mockServer.takeRequest() - assertEquals(1, mockServer.requestCount, "One http request expected") - assertTrue(binding.error.isVisible, "Error should be shown") - -// assertEquals( -// FabStates.ERROR, -// fabAction, -// "Text should not be a valid image format, error state expected" -// ) - assertEquals( - "Image format not supported", - errorRef?.message, - "Error message mismatch" - ) - assertFalse(tempFile?.exists() == true, "Temp file should have been removed") - } - - @Test - fun errorTest() = launchScenario(mockServer.url("error").toString()) { - mockServer.takeRequest() - assertEquals(1, mockServer.requestCount, "One http request expected") - assertTrue(binding.error.isVisible, "Error should be shown") -// assertEquals(FabStates.ERROR, fabAction, "Error response code, error state expected") - assertEquals( - "Unsuccessful response for image: Error mock response", - errorRef?.message, - "Error message mismatch" - ) - assertFalse(tempFile?.exists() == true, "Temp file should have been removed") - } - - private fun launchScenario( - imageUrl: String, - text: String? = null, - cookie: String? = null, - action: ImageActivity.() -> Unit - ) { - assertFalse( - imageUrl.isIndirectImageUrl, - "For simplicity, urls that are direct will be used without modifications in the production code." - ) - val intent = - Intent(ApplicationProvider.getApplicationContext(), ImageActivity::class.java).apply { - putExtra(ARG_IMAGE_URL, imageUrl) - putExtra(ARG_TEXT, text) - putExtra(ARG_COOKIE, cookie) + private fun mockServer(): MockWebServer { + val img = Buffer() + img.writeAll(getResource("bayer-pattern.jpg").source()) + return MockWebServer().apply { + dispatcher = + object : Dispatcher() { + override fun dispatch(request: RecordedRequest): MockResponse = + when { + request.path?.contains("text") == true -> + MockResponse().setResponseCode(200).setBody("Valid mock text response") + request.path?.contains("image") == true -> + MockResponse().setResponseCode(200).setBody(img) + else -> MockResponse().setResponseCode(404).setBody("Error mock response") } - ActivityScenario.launch(intent).use { - it.onActivity(action) - } - } - - private fun mockServer(): MockWebServer { - val img = Buffer() - img.writeAll(getResource("bayer-pattern.jpg").source()) - return MockWebServer().apply { - dispatcher = object : Dispatcher() { - override fun dispatch(request: RecordedRequest): MockResponse = - when { - request.path?.contains("text") == true -> MockResponse().setResponseCode(200) - .setBody( - "Valid mock text response" - ) - request.path?.contains("image") == true -> MockResponse().setResponseCode( - 200 - ).setBody( - img - ) - else -> MockResponse().setResponseCode(404).setBody("Error mock response") - } - } - start() } + start() } + } } diff --git a/app/src/androidTest/kotlin/com/pitchedapps/frost/activities/IntroActivityTest.kt b/app/src/androidTest/kotlin/com/pitchedapps/frost/activities/IntroActivityTest.kt index 0fea49fed..0392bee20 100644 --- a/app/src/androidTest/kotlin/com/pitchedapps/frost/activities/IntroActivityTest.kt +++ b/app/src/androidTest/kotlin/com/pitchedapps/frost/activities/IntroActivityTest.kt @@ -25,18 +25,16 @@ import org.junit.Test @HiltAndroidTest class IntroActivityTest { - @get:Rule(order = 0) - val hildAndroidRule = HiltAndroidRule(this) + @get:Rule(order = 0) val hildAndroidRule = HiltAndroidRule(this) - @get:Rule(order = 1) - val activityRule = activityRule() + @get:Rule(order = 1) val activityRule = activityRule() - @Test - fun initializesSuccessfully() { - activityRule.scenario.use { - it.onActivity { - // Verify no crash - } - } + @Test + fun initializesSuccessfully() { + activityRule.scenario.use { + it.onActivity { + // Verify no crash + } } + } } diff --git a/app/src/androidTest/kotlin/com/pitchedapps/frost/activities/LoginActivityTest.kt b/app/src/androidTest/kotlin/com/pitchedapps/frost/activities/LoginActivityTest.kt index 1a77d00cf..6bf9eb13f 100644 --- a/app/src/androidTest/kotlin/com/pitchedapps/frost/activities/LoginActivityTest.kt +++ b/app/src/androidTest/kotlin/com/pitchedapps/frost/activities/LoginActivityTest.kt @@ -25,18 +25,16 @@ import org.junit.Test @HiltAndroidTest class LoginActivityTest { - @get:Rule(order = 0) - val hildAndroidRule = HiltAndroidRule(this) + @get:Rule(order = 0) val hildAndroidRule = HiltAndroidRule(this) - @get:Rule(order = 1) - val activityRule = activityRule() + @get:Rule(order = 1) val activityRule = activityRule() - @Test - fun initializesSuccessfully() { - activityRule.scenario.use { - it.onActivity { - // Verify no crash - } - } + @Test + fun initializesSuccessfully() { + activityRule.scenario.use { + it.onActivity { + // Verify no crash + } } + } } diff --git a/app/src/androidTest/kotlin/com/pitchedapps/frost/activities/MainActivityTest.kt b/app/src/androidTest/kotlin/com/pitchedapps/frost/activities/MainActivityTest.kt index b5c01e311..6ec433955 100644 --- a/app/src/androidTest/kotlin/com/pitchedapps/frost/activities/MainActivityTest.kt +++ b/app/src/androidTest/kotlin/com/pitchedapps/frost/activities/MainActivityTest.kt @@ -25,18 +25,16 @@ import org.junit.Test @HiltAndroidTest class MainActivityTest { - @get:Rule(order = 0) - val hildAndroidRule = HiltAndroidRule(this) + @get:Rule(order = 0) val hildAndroidRule = HiltAndroidRule(this) - @get:Rule(order = 1) - val activityRule = activityRule() + @get:Rule(order = 1) val activityRule = activityRule() - @Test - fun initializesSuccessfully() { - activityRule.scenario.use { - it.onActivity { - // Verify no crash - } - } + @Test + fun initializesSuccessfully() { + activityRule.scenario.use { + it.onActivity { + // Verify no crash + } } + } } diff --git a/app/src/androidTest/kotlin/com/pitchedapps/frost/activities/SelectorActivityTest.kt b/app/src/androidTest/kotlin/com/pitchedapps/frost/activities/SelectorActivityTest.kt index 7f3b12902..7dddb057e 100644 --- a/app/src/androidTest/kotlin/com/pitchedapps/frost/activities/SelectorActivityTest.kt +++ b/app/src/androidTest/kotlin/com/pitchedapps/frost/activities/SelectorActivityTest.kt @@ -25,18 +25,16 @@ import org.junit.Test @HiltAndroidTest class SelectorActivityTest { - @get:Rule(order = 0) - val hildAndroidRule = HiltAndroidRule(this) + @get:Rule(order = 0) val hildAndroidRule = HiltAndroidRule(this) - @get:Rule(order = 1) - val activityRule = activityRule() + @get:Rule(order = 1) val activityRule = activityRule() - @Test - fun initializesSuccessfully() { - activityRule.scenario.use { - it.onActivity { - // Verify no crash - } - } + @Test + fun initializesSuccessfully() { + activityRule.scenario.use { + it.onActivity { + // Verify no crash + } } + } } diff --git a/app/src/androidTest/kotlin/com/pitchedapps/frost/activities/SettingActivityTest.kt b/app/src/androidTest/kotlin/com/pitchedapps/frost/activities/SettingActivityTest.kt index 026498a11..642bac725 100644 --- a/app/src/androidTest/kotlin/com/pitchedapps/frost/activities/SettingActivityTest.kt +++ b/app/src/androidTest/kotlin/com/pitchedapps/frost/activities/SettingActivityTest.kt @@ -25,18 +25,16 @@ import org.junit.Test @HiltAndroidTest class SettingActivityTest { - @get:Rule(order = 0) - val hildAndroidRule = HiltAndroidRule(this) + @get:Rule(order = 0) val hildAndroidRule = HiltAndroidRule(this) - @get:Rule(order = 1) - val activityRule = activityRule() + @get:Rule(order = 1) val activityRule = activityRule() - @Test - fun initializesSuccessfully() { - activityRule.scenario.use { - it.onActivity { - // Verify no crash - } - } + @Test + fun initializesSuccessfully() { + activityRule.scenario.use { + it.onActivity { + // Verify no crash + } } + } } diff --git a/app/src/androidTest/kotlin/com/pitchedapps/frost/activities/TabCustomizerActivityTest.kt b/app/src/androidTest/kotlin/com/pitchedapps/frost/activities/TabCustomizerActivityTest.kt index ed30b809f..310ef09a2 100644 --- a/app/src/androidTest/kotlin/com/pitchedapps/frost/activities/TabCustomizerActivityTest.kt +++ b/app/src/androidTest/kotlin/com/pitchedapps/frost/activities/TabCustomizerActivityTest.kt @@ -25,18 +25,16 @@ import org.junit.Test @HiltAndroidTest class TabCustomizerActivityTest { - @get:Rule(order = 0) - val hildAndroidRule = HiltAndroidRule(this) + @get:Rule(order = 0) val hildAndroidRule = HiltAndroidRule(this) - @get:Rule(order = 1) - val activityRule = activityRule() + @get:Rule(order = 1) val activityRule = activityRule() - @Test - fun initializesSuccessfully() { - activityRule.scenario.use { - it.onActivity { - // Verify no crash - } - } + @Test + fun initializesSuccessfully() { + activityRule.scenario.use { + it.onActivity { + // Verify no crash + } } + } } diff --git a/app/src/androidTest/kotlin/com/pitchedapps/frost/activities/WebOverlayActivityTest.kt b/app/src/androidTest/kotlin/com/pitchedapps/frost/activities/WebOverlayActivityTest.kt index dac81fe74..29ff81a8d 100644 --- a/app/src/androidTest/kotlin/com/pitchedapps/frost/activities/WebOverlayActivityTest.kt +++ b/app/src/androidTest/kotlin/com/pitchedapps/frost/activities/WebOverlayActivityTest.kt @@ -25,18 +25,16 @@ import org.junit.Test @HiltAndroidTest class WebOverlayActivityTest { - @get:Rule(order = 0) - val hildAndroidRule = HiltAndroidRule(this) + @get:Rule(order = 0) val hildAndroidRule = HiltAndroidRule(this) - @get:Rule(order = 1) - val activityRule = activityRule() + @get:Rule(order = 1) val activityRule = activityRule() - @Test - fun initializesSuccessfully() { - activityRule.scenario.use { - it.onActivity { - // Verify no crash - } - } + @Test + fun initializesSuccessfully() { + activityRule.scenario.use { + it.onActivity { + // Verify no crash + } } + } } diff --git a/app/src/androidTest/kotlin/com/pitchedapps/frost/db/BaseDbTest.kt b/app/src/androidTest/kotlin/com/pitchedapps/frost/db/BaseDbTest.kt index bae56e2fb..be33b6f95 100644 --- a/app/src/androidTest/kotlin/com/pitchedapps/frost/db/BaseDbTest.kt +++ b/app/src/androidTest/kotlin/com/pitchedapps/frost/db/BaseDbTest.kt @@ -20,29 +20,25 @@ import android.content.Context import androidx.room.Room import androidx.test.core.app.ApplicationProvider import androidx.test.ext.junit.runners.AndroidJUnit4 -import org.junit.runner.RunWith import kotlin.test.AfterTest import kotlin.test.BeforeTest +import org.junit.runner.RunWith @RunWith(AndroidJUnit4::class) abstract class BaseDbTest { - protected lateinit var db: FrostDatabase + protected lateinit var db: FrostDatabase - @BeforeTest - fun before() { - val context = ApplicationProvider.getApplicationContext() - val privateDb = Room.inMemoryDatabaseBuilder( - context, FrostPrivateDatabase::class.java - ).build() - val publicDb = Room.inMemoryDatabaseBuilder( - context, FrostPublicDatabase::class.java - ).build() - db = FrostDatabase(privateDb, publicDb) - } + @BeforeTest + fun before() { + val context = ApplicationProvider.getApplicationContext() + val privateDb = Room.inMemoryDatabaseBuilder(context, FrostPrivateDatabase::class.java).build() + val publicDb = Room.inMemoryDatabaseBuilder(context, FrostPublicDatabase::class.java).build() + db = FrostDatabase(privateDb, publicDb) + } - @AfterTest - fun after() { - db.close() - } + @AfterTest + fun after() { + db.close() + } } diff --git a/app/src/androidTest/kotlin/com/pitchedapps/frost/db/CacheDbTest.kt b/app/src/androidTest/kotlin/com/pitchedapps/frost/db/CacheDbTest.kt index 417c66788..a9b6e93ab 100644 --- a/app/src/androidTest/kotlin/com/pitchedapps/frost/db/CacheDbTest.kt +++ b/app/src/androidTest/kotlin/com/pitchedapps/frost/db/CacheDbTest.kt @@ -16,33 +16,35 @@ */ package com.pitchedapps.frost.db -import kotlinx.coroutines.runBlocking import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertTrue import kotlin.test.fail +import kotlinx.coroutines.runBlocking class CacheDbTest : BaseDbTest() { - private val dao get() = db.cacheDao() - private val cookieDao get() = db.cookieDao() + private val dao + get() = db.cacheDao() + private val cookieDao + get() = db.cookieDao() - private fun cookie(id: Long) = CookieEntity(id, "name$id", "cookie$id") + private fun cookie(id: Long) = CookieEntity(id, "name$id", "cookie$id") - @Test - fun save() { - val cookie = cookie(1L) - val type = "test" - val content = "long test".repeat(10000) - runBlocking { - cookieDao.save(cookie) - dao.save(cookie.id, type, content) - val cache = dao.select(cookie.id, type) ?: fail("Cache not found") - assertEquals(content, cache.contents, "Content mismatch") - assertTrue( - System.currentTimeMillis() - cache.lastUpdated < 500, - "Cache retrieval took over 500ms (${System.currentTimeMillis() - cache.lastUpdated})" - ) - } + @Test + fun save() { + val cookie = cookie(1L) + val type = "test" + val content = "long test".repeat(10000) + runBlocking { + cookieDao.save(cookie) + dao.save(cookie.id, type, content) + val cache = dao.select(cookie.id, type) ?: fail("Cache not found") + assertEquals(content, cache.contents, "Content mismatch") + assertTrue( + System.currentTimeMillis() - cache.lastUpdated < 500, + "Cache retrieval took over 500ms (${System.currentTimeMillis() - cache.lastUpdated})" + ) } + } } diff --git a/app/src/androidTest/kotlin/com/pitchedapps/frost/db/CookieDbTest.kt b/app/src/androidTest/kotlin/com/pitchedapps/frost/db/CookieDbTest.kt index 327ead86a..d41e74043 100644 --- a/app/src/androidTest/kotlin/com/pitchedapps/frost/db/CookieDbTest.kt +++ b/app/src/androidTest/kotlin/com/pitchedapps/frost/db/CookieDbTest.kt @@ -16,70 +16,71 @@ */ package com.pitchedapps.frost.db -import kotlinx.coroutines.runBlocking import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertNull +import kotlinx.coroutines.runBlocking class CookieDbTest : BaseDbTest() { - private val dao get() = db.cookieDao() + private val dao + get() = db.cookieDao() - @Test - fun basicCookie() { - val cookie = CookieEntity(id = 1234L, name = "testName", cookie = "testCookie") - runBlocking { - dao.save(cookie) - val cookies = dao.selectAll() - assertEquals(listOf(cookie), cookies, "Cookie mismatch") - } + @Test + fun basicCookie() { + val cookie = CookieEntity(id = 1234L, name = "testName", cookie = "testCookie") + runBlocking { + dao.save(cookie) + val cookies = dao.selectAll() + assertEquals(listOf(cookie), cookies, "Cookie mismatch") } + } - @Test - fun deleteCookie() { - val cookie = CookieEntity(id = 1234L, name = "testName", cookie = "testCookie") + @Test + fun deleteCookie() { + val cookie = CookieEntity(id = 1234L, name = "testName", cookie = "testCookie") - runBlocking { - dao.save(cookie) - dao.deleteById(cookie.id + 1) - assertEquals( - listOf(cookie), - dao.selectAll(), - "Cookie list should be the same after inexistent deletion" - ) - dao.deleteById(cookie.id) - assertEquals(emptyList(), dao.selectAll(), "Cookie list should be empty after deletion") - } + runBlocking { + dao.save(cookie) + dao.deleteById(cookie.id + 1) + assertEquals( + listOf(cookie), + dao.selectAll(), + "Cookie list should be the same after inexistent deletion" + ) + dao.deleteById(cookie.id) + assertEquals(emptyList(), dao.selectAll(), "Cookie list should be empty after deletion") } + } - @Test - fun insertReplaceCookie() { - val cookie = CookieEntity(id = 1234L, name = "testName", cookie = "testCookie") - runBlocking { - dao.save(cookie) - assertEquals(listOf(cookie), dao.selectAll(), "Cookie insertion failed") - dao.save(cookie.copy(name = "testName2")) - assertEquals( - listOf(cookie.copy(name = "testName2")), - dao.selectAll(), - "Cookie replacement failed" - ) - dao.save(cookie.copy(id = 123L)) - assertEquals( - setOf(cookie.copy(id = 123L), cookie.copy(name = "testName2")), - dao.selectAll().toSet(), - "New cookie insertion failed" - ) - } + @Test + fun insertReplaceCookie() { + val cookie = CookieEntity(id = 1234L, name = "testName", cookie = "testCookie") + runBlocking { + dao.save(cookie) + assertEquals(listOf(cookie), dao.selectAll(), "Cookie insertion failed") + dao.save(cookie.copy(name = "testName2")) + assertEquals( + listOf(cookie.copy(name = "testName2")), + dao.selectAll(), + "Cookie replacement failed" + ) + dao.save(cookie.copy(id = 123L)) + assertEquals( + setOf(cookie.copy(id = 123L), cookie.copy(name = "testName2")), + dao.selectAll().toSet(), + "New cookie insertion failed" + ) } + } - @Test - fun selectCookie() { - val cookie = CookieEntity(id = 1234L, name = "testName", cookie = "testCookie") - runBlocking { - dao.save(cookie) - assertEquals(cookie, dao.selectById(cookie.id), "Cookie selection failed") - assertNull(dao.selectById(cookie.id + 1), "Inexistent cookie selection failed") - } + @Test + fun selectCookie() { + val cookie = CookieEntity(id = 1234L, name = "testName", cookie = "testCookie") + runBlocking { + dao.save(cookie) + assertEquals(cookie, dao.selectById(cookie.id), "Cookie selection failed") + assertNull(dao.selectById(cookie.id + 1), "Inexistent cookie selection failed") } + } } diff --git a/app/src/androidTest/kotlin/com/pitchedapps/frost/db/CookieMigrationTest.kt b/app/src/androidTest/kotlin/com/pitchedapps/frost/db/CookieMigrationTest.kt index 8da7c6630..460f445c9 100644 --- a/app/src/androidTest/kotlin/com/pitchedapps/frost/db/CookieMigrationTest.kt +++ b/app/src/androidTest/kotlin/com/pitchedapps/frost/db/CookieMigrationTest.kt @@ -21,38 +21,40 @@ import androidx.room.testing.MigrationTestHelper import androidx.sqlite.db.framework.FrameworkSQLiteOpenHelperFactory import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry -import org.junit.runner.RunWith import kotlin.test.Test +import org.junit.runner.RunWith @RunWith(AndroidJUnit4::class) class CookieMigrationTest { - private val TEST_DB = "cookie_migration_test" + private val TEST_DB = "cookie_migration_test" - private val ALL_MIGRATIONS = arrayOf(COOKIES_MIGRATION_1_2) + private val ALL_MIGRATIONS = arrayOf(COOKIES_MIGRATION_1_2) - val helper: MigrationTestHelper = MigrationTestHelper( - InstrumentationRegistry.getInstrumentation(), - FrostPrivateDatabase::class.java.canonicalName, - FrameworkSQLiteOpenHelperFactory() + val helper: MigrationTestHelper = + MigrationTestHelper( + InstrumentationRegistry.getInstrumentation(), + FrostPrivateDatabase::class.java.canonicalName, + FrameworkSQLiteOpenHelperFactory() ) - @Test - fun migrateAll() { - // Create earliest version of the database. - helper.createDatabase(TEST_DB, 1).apply { - close() - } + @Test + fun migrateAll() { + // Create earliest version of the database. + helper.createDatabase(TEST_DB, 1).apply { close() } - // Open latest version of the database. Room will validate the schema - // once all migrations execute. - Room.databaseBuilder( - InstrumentationRegistry.getInstrumentation().targetContext, - FrostPrivateDatabase::class.java, - TEST_DB - ).addMigrations(*ALL_MIGRATIONS).build().apply { - openHelper.writableDatabase - close() - } - } + // Open latest version of the database. Room will validate the schema + // once all migrations execute. + Room.databaseBuilder( + InstrumentationRegistry.getInstrumentation().targetContext, + FrostPrivateDatabase::class.java, + TEST_DB + ) + .addMigrations(*ALL_MIGRATIONS) + .build() + .apply { + openHelper.writableDatabase + close() + } + } } diff --git a/app/src/androidTest/kotlin/com/pitchedapps/frost/db/GenericDbTest.kt b/app/src/androidTest/kotlin/com/pitchedapps/frost/db/GenericDbTest.kt index c911ddf61..61b0f2327 100644 --- a/app/src/androidTest/kotlin/com/pitchedapps/frost/db/GenericDbTest.kt +++ b/app/src/androidTest/kotlin/com/pitchedapps/frost/db/GenericDbTest.kt @@ -18,56 +18,54 @@ package com.pitchedapps.frost.db import com.pitchedapps.frost.facebook.FbItem import com.pitchedapps.frost.facebook.defaultTabs -import kotlinx.coroutines.runBlocking import kotlin.test.Test import kotlin.test.assertEquals +import kotlinx.coroutines.runBlocking class GenericDbTest : BaseDbTest() { - private val dao get() = db.genericDao() + private val dao + get() = db.genericDao() - /** - * Note that order is also preserved here - */ - @Test - fun save() { - val tabs = listOf( - FbItem.ACTIVITY_LOG, - FbItem.BIRTHDAYS, - FbItem.EVENTS, - FbItem.MARKETPLACE, - FbItem.ACTIVITY_LOG + /** Note that order is also preserved here */ + @Test + fun save() { + val tabs = + listOf( + FbItem.ACTIVITY_LOG, + FbItem.BIRTHDAYS, + FbItem.EVENTS, + FbItem.MARKETPLACE, + FbItem.ACTIVITY_LOG + ) + runBlocking { + dao.saveTabs(tabs) + assertEquals(tabs, dao.getTabs(), "Tab saving failed") + val newTabs = listOf(FbItem.PAGES, FbItem.MENU) + dao.saveTabs(newTabs) + assertEquals(newTabs, dao.getTabs(), "Tab overwrite failed") + } + } + + @Test + fun defaultRetrieve() { + runBlocking { assertEquals(defaultTabs(), dao.getTabs(), "Default retrieval failed") } + } + + @Test + fun ignoreErrors() { + runBlocking { + dao._save( + GenericEntity( + GenericDao.TYPE_TABS, + "${FbItem.ACTIVITY_LOG.name},unknown,${FbItem.EVENTS.name}" ) - runBlocking { - dao.saveTabs(tabs) - assertEquals(tabs, dao.getTabs(), "Tab saving failed") - val newTabs = listOf(FbItem.PAGES, FbItem.MENU) - dao.saveTabs(newTabs) - assertEquals(newTabs, dao.getTabs(), "Tab overwrite failed") - } - } - - @Test - fun defaultRetrieve() { - runBlocking { - assertEquals(defaultTabs(), dao.getTabs(), "Default retrieval failed") - } - } - - @Test - fun ignoreErrors() { - runBlocking { - dao._save( - GenericEntity( - GenericDao.TYPE_TABS, - "${FbItem.ACTIVITY_LOG.name},unknown,${FbItem.EVENTS.name}" - ) - ) - assertEquals( - listOf(FbItem.ACTIVITY_LOG, FbItem.EVENTS), - dao.getTabs(), - "Tab fetching does not ignore unknown names" - ) - } + ) + assertEquals( + listOf(FbItem.ACTIVITY_LOG, FbItem.EVENTS), + dao.getTabs(), + "Tab fetching does not ignore unknown names" + ) } + } } diff --git a/app/src/androidTest/kotlin/com/pitchedapps/frost/db/NotificationDbTest.kt b/app/src/androidTest/kotlin/com/pitchedapps/frost/db/NotificationDbTest.kt index 6fb013503..6c0171d91 100644 --- a/app/src/androidTest/kotlin/com/pitchedapps/frost/db/NotificationDbTest.kt +++ b/app/src/androidTest/kotlin/com/pitchedapps/frost/db/NotificationDbTest.kt @@ -19,139 +19,134 @@ package com.pitchedapps.frost.db import com.pitchedapps.frost.services.NOTIF_CHANNEL_GENERAL import com.pitchedapps.frost.services.NOTIF_CHANNEL_MESSAGES import com.pitchedapps.frost.services.NotificationContent -import kotlinx.coroutines.runBlocking import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertTrue +import kotlinx.coroutines.runBlocking class NotificationDbTest : BaseDbTest() { - private val dao get() = db.notifDao() + private val dao + get() = db.notifDao() - private fun cookie(id: Long) = CookieEntity(id, "name$id", "cookie$id") + private fun cookie(id: Long) = CookieEntity(id, "name$id", "cookie$id") - private fun notifContent(id: Long, cookie: CookieEntity, time: Long = id) = NotificationContent( - data = cookie, - id = id, - href = "", - title = null, - text = "", - timestamp = time, - profileUrl = null, - unread = true + private fun notifContent(id: Long, cookie: CookieEntity, time: Long = id) = + NotificationContent( + data = cookie, + id = id, + href = "", + title = null, + text = "", + timestamp = time, + profileUrl = null, + unread = true ) - @Test - fun saveAndRetrieve() { - val cookie = cookie(12345L) - // Unique unsorted ids - val notifs = listOf(0L, 4L, 2L, 6L, 99L, 3L).map { notifContent(it, cookie) } - runBlocking { - db.cookieDao().save(cookie) - dao.saveNotifications(NOTIF_CHANNEL_GENERAL, notifs) - val dbNotifs = dao.selectNotifications(cookie.id, NOTIF_CHANNEL_GENERAL) - assertEquals( - notifs.sortedByDescending { it.timestamp }, - dbNotifs, - "Incorrect notification list received" - ) - } + @Test + fun saveAndRetrieve() { + val cookie = cookie(12345L) + // Unique unsorted ids + val notifs = listOf(0L, 4L, 2L, 6L, 99L, 3L).map { notifContent(it, cookie) } + runBlocking { + db.cookieDao().save(cookie) + dao.saveNotifications(NOTIF_CHANNEL_GENERAL, notifs) + val dbNotifs = dao.selectNotifications(cookie.id, NOTIF_CHANNEL_GENERAL) + assertEquals( + notifs.sortedByDescending { it.timestamp }, + dbNotifs, + "Incorrect notification list received" + ) } + } - @Test - fun selectConditions() { - runBlocking { - val cookie1 = cookie(12345L) - val cookie2 = cookie(12L) - val notifs1 = (0L..2L).map { notifContent(it, cookie1) } - val notifs2 = (5L..10L).map { notifContent(it, cookie2) } - db.cookieDao().save(cookie1) - db.cookieDao().save(cookie2) - dao.saveNotifications(NOTIF_CHANNEL_GENERAL, notifs1) - dao.saveNotifications(NOTIF_CHANNEL_MESSAGES, notifs2) - assertEquals( - emptyList(), - dao.selectNotifications(cookie1.id, NOTIF_CHANNEL_MESSAGES), - "Filtering by type did not work for cookie1" - ) - assertEquals( - notifs1.sortedByDescending { it.timestamp }, - dao.selectNotifications(cookie1.id, NOTIF_CHANNEL_GENERAL), - "Selection for cookie1 failed" - ) - assertEquals( - emptyList(), - dao.selectNotifications(cookie2.id, NOTIF_CHANNEL_GENERAL), - "Filtering by type did not work for cookie2" - ) - assertEquals( - notifs2.sortedByDescending { it.timestamp }, - dao.selectNotifications(cookie2.id, NOTIF_CHANNEL_MESSAGES), - "Selection for cookie2 failed" - ) - } + @Test + fun selectConditions() { + runBlocking { + val cookie1 = cookie(12345L) + val cookie2 = cookie(12L) + val notifs1 = (0L..2L).map { notifContent(it, cookie1) } + val notifs2 = (5L..10L).map { notifContent(it, cookie2) } + db.cookieDao().save(cookie1) + db.cookieDao().save(cookie2) + dao.saveNotifications(NOTIF_CHANNEL_GENERAL, notifs1) + dao.saveNotifications(NOTIF_CHANNEL_MESSAGES, notifs2) + assertEquals( + emptyList(), + dao.selectNotifications(cookie1.id, NOTIF_CHANNEL_MESSAGES), + "Filtering by type did not work for cookie1" + ) + assertEquals( + notifs1.sortedByDescending { it.timestamp }, + dao.selectNotifications(cookie1.id, NOTIF_CHANNEL_GENERAL), + "Selection for cookie1 failed" + ) + assertEquals( + emptyList(), + dao.selectNotifications(cookie2.id, NOTIF_CHANNEL_GENERAL), + "Filtering by type did not work for cookie2" + ) + assertEquals( + notifs2.sortedByDescending { it.timestamp }, + dao.selectNotifications(cookie2.id, NOTIF_CHANNEL_MESSAGES), + "Selection for cookie2 failed" + ) } + } - /** - * Primary key is both id and userId, in the event that the same notification to multiple users has the same id - */ - @Test - fun primaryKeyCheck() { - runBlocking { - val cookie1 = cookie(12345L) - val cookie2 = cookie(12L) - val notifs1 = (0L..2L).map { notifContent(it, cookie1) } - val notifs2 = notifs1.map { it.copy(data = cookie2) } - db.cookieDao().save(cookie1) - db.cookieDao().save(cookie2) - assertTrue(dao.saveNotifications(NOTIF_CHANNEL_GENERAL, notifs1), "Notif1 save failed") - assertTrue(dao.saveNotifications(NOTIF_CHANNEL_GENERAL, notifs2), "Notif2 save failed") - } + /** + * Primary key is both id and userId, in the event that the same notification to multiple users + * has the same id + */ + @Test + fun primaryKeyCheck() { + runBlocking { + val cookie1 = cookie(12345L) + val cookie2 = cookie(12L) + val notifs1 = (0L..2L).map { notifContent(it, cookie1) } + val notifs2 = notifs1.map { it.copy(data = cookie2) } + db.cookieDao().save(cookie1) + db.cookieDao().save(cookie2) + assertTrue(dao.saveNotifications(NOTIF_CHANNEL_GENERAL, notifs1), "Notif1 save failed") + assertTrue(dao.saveNotifications(NOTIF_CHANNEL_GENERAL, notifs2), "Notif2 save failed") } + } - @Test - fun cascadeDeletion() { - val cookie = cookie(12345L) - // Unique unsorted ids - val notifs = listOf(0L, 4L, 2L, 6L, 99L, 3L).map { notifContent(it, cookie) } - runBlocking { - db.cookieDao().save(cookie) - dao.saveNotifications(NOTIF_CHANNEL_GENERAL, notifs) - db.cookieDao().deleteById(cookie.id) - val dbNotifs = dao.selectNotifications(cookie.id, NOTIF_CHANNEL_GENERAL) - assertTrue(dbNotifs.isEmpty(), "Cascade deletion failed") - } + @Test + fun cascadeDeletion() { + val cookie = cookie(12345L) + // Unique unsorted ids + val notifs = listOf(0L, 4L, 2L, 6L, 99L, 3L).map { notifContent(it, cookie) } + runBlocking { + db.cookieDao().save(cookie) + dao.saveNotifications(NOTIF_CHANNEL_GENERAL, notifs) + db.cookieDao().deleteById(cookie.id) + val dbNotifs = dao.selectNotifications(cookie.id, NOTIF_CHANNEL_GENERAL) + assertTrue(dbNotifs.isEmpty(), "Cascade deletion failed") } + } - @Test - fun latestEpoch() { - val cookie = cookie(12345L) - // Unique unsorted ids - val notifs = listOf(0L, 4L, 2L, 6L, 99L, 3L).map { notifContent(it, cookie) } - runBlocking { - assertEquals( - -1L, - dao.latestEpoch(cookie.id, NOTIF_CHANNEL_GENERAL), - "Default epoch failed" - ) - db.cookieDao().save(cookie) - dao.saveNotifications(NOTIF_CHANNEL_GENERAL, notifs) - assertEquals( - 99L, - dao.latestEpoch(cookie.id, NOTIF_CHANNEL_GENERAL), - "Latest epoch failed" - ) - } + @Test + fun latestEpoch() { + val cookie = cookie(12345L) + // Unique unsorted ids + val notifs = listOf(0L, 4L, 2L, 6L, 99L, 3L).map { notifContent(it, cookie) } + runBlocking { + assertEquals(-1L, dao.latestEpoch(cookie.id, NOTIF_CHANNEL_GENERAL), "Default epoch failed") + db.cookieDao().save(cookie) + dao.saveNotifications(NOTIF_CHANNEL_GENERAL, notifs) + assertEquals(99L, dao.latestEpoch(cookie.id, NOTIF_CHANNEL_GENERAL), "Latest epoch failed") } + } - @Test - fun insertionWithInvalidCookies() { - runBlocking { - assertFalse( - dao.saveNotifications(NOTIF_CHANNEL_GENERAL, listOf(notifContent(1L, cookie(2L)))), - "Notif save should not have passed without relevant cookie entries" - ) - } + @Test + fun insertionWithInvalidCookies() { + runBlocking { + assertFalse( + dao.saveNotifications(NOTIF_CHANNEL_GENERAL, listOf(notifContent(1L, cookie(2L)))), + "Notif save should not have passed without relevant cookie entries" + ) } + } } diff --git a/app/src/androidTest/kotlin/com/pitchedapps/frost/facebook/FbCookieTest.kt b/app/src/androidTest/kotlin/com/pitchedapps/frost/facebook/FbCookieTest.kt index 71391b5a5..2053f18d8 100644 --- a/app/src/androidTest/kotlin/com/pitchedapps/frost/facebook/FbCookieTest.kt +++ b/app/src/androidTest/kotlin/com/pitchedapps/frost/facebook/FbCookieTest.kt @@ -17,16 +17,16 @@ package com.pitchedapps.frost.facebook import android.webkit.CookieManager -import org.junit.Test import kotlin.test.assertTrue +import org.junit.Test class FbCookieTest { - @Test - fun managerAcceptsCookie() { - assertTrue( - CookieManager.getInstance().acceptCookie(), - "Cookie manager should accept cookie by default" - ) - } + @Test + fun managerAcceptsCookie() { + assertTrue( + CookieManager.getInstance().acceptCookie(), + "Cookie manager should accept cookie by default" + ) + } } diff --git a/app/src/androidTest/kotlin/com/pitchedapps/frost/helper/Helper.kt b/app/src/androidTest/kotlin/com/pitchedapps/frost/helper/Helper.kt index 52bf4494f..53bab2251 100644 --- a/app/src/androidTest/kotlin/com/pitchedapps/frost/helper/Helper.kt +++ b/app/src/androidTest/kotlin/com/pitchedapps/frost/helper/Helper.kt @@ -26,23 +26,21 @@ import androidx.test.platform.app.InstrumentationRegistry import java.io.InputStream val context: Context - get() = InstrumentationRegistry.getInstrumentation().targetContext + get() = InstrumentationRegistry.getInstrumentation().targetContext -fun getAsset(asset: String): InputStream = - context.assets.open(asset) +fun getAsset(asset: String): InputStream = context.assets.open(asset) private class Helper fun getResource(resource: String): InputStream = - Helper::class.java.classLoader!!.getResource(resource).openStream() + Helper::class.java.classLoader!!.getResource(resource).openStream() inline fun activityRule( - intentAction: Intent.() -> Unit = {}, - activityOptions: Bundle? = null + intentAction: Intent.() -> Unit = {}, + activityOptions: Bundle? = null ): ActivityScenarioRule { - val intent = - Intent(ApplicationProvider.getApplicationContext(), A::class.java).also(intentAction) - return ActivityScenarioRule(intent, activityOptions) + val intent = Intent(ApplicationProvider.getApplicationContext(), A::class.java).also(intentAction) + return ActivityScenarioRule(intent, activityOptions) } const val TEST_FORMATTED_URL = "https://www.google.com" diff --git a/app/src/main/kotlin/com/pitchedapps/frost/FrostApp.kt b/app/src/main/kotlin/com/pitchedapps/frost/FrostApp.kt index d444d225e..4a6f490c3 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/FrostApp.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/FrostApp.kt @@ -37,78 +37,75 @@ import dagger.hilt.android.HiltAndroidApp import java.util.Random import javax.inject.Inject -/** - * Created by Allan Wang on 2017-05-28. - */ +/** Created by Allan Wang on 2017-05-28. */ @HiltAndroidApp class FrostApp : Application() { - @Inject - lateinit var prefs: Prefs + @Inject lateinit var prefs: Prefs - @Inject - lateinit var themeProvider: ThemeProvider + @Inject lateinit var themeProvider: ThemeProvider - @Inject - lateinit var cookieDao: CookieDao + @Inject lateinit var cookieDao: CookieDao - @Inject - lateinit var notifDao: NotificationDao + @Inject lateinit var notifDao: NotificationDao - override fun onCreate() { - super.onCreate() + override fun onCreate() { + super.onCreate() - if (!buildIsLollipopAndUp) return // not supported + if (!buildIsLollipopAndUp) return // not supported - initPrefs() + initPrefs() - L.i { "Begin Frost for Facebook" } - FrostPglAdBlock.init(this) + L.i { "Begin Frost for Facebook" } + FrostPglAdBlock.init(this) - setupNotificationChannels(this, themeProvider) + setupNotificationChannels(this, themeProvider) - scheduleNotificationsFromPrefs(prefs) + scheduleNotificationsFromPrefs(prefs) - BigImageViewer.initialize(GlideImageLoader.with(this, httpClient)) + BigImageViewer.initialize(GlideImageLoader.with(this, httpClient)) - if (BuildConfig.DEBUG) { - registerActivityLifecycleCallbacks(object : ActivityLifecycleCallbacks { - override fun onActivityPaused(activity: Activity) {} - override fun onActivityResumed(activity: Activity) {} - override fun onActivityStarted(activity: Activity) {} + if (BuildConfig.DEBUG) { + registerActivityLifecycleCallbacks( + object : ActivityLifecycleCallbacks { + override fun onActivityPaused(activity: Activity) {} + override fun onActivityResumed(activity: Activity) {} + override fun onActivityStarted(activity: Activity) {} - override fun onActivityDestroyed(activity: Activity) { - L.d { "Activity ${activity.localClassName} destroyed" } - } + override fun onActivityDestroyed(activity: Activity) { + L.d { "Activity ${activity.localClassName} destroyed" } + } - override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) {} + override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) {} - override fun onActivityStopped(activity: Activity) {} + override fun onActivityStopped(activity: Activity) {} - override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) { - L.d { "Activity ${activity.localClassName} created" } - } - }) + override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) { + L.d { "Activity ${activity.localClassName} created" } + } } + ) } + } - private fun initPrefs() { - prefs.deleteKeys("search_bar", "shown_release", "experimental_by_default") - KL.shouldLog = { BuildConfig.DEBUG } - L.shouldLog = { - when (it) { - Log.VERBOSE -> BuildConfig.DEBUG - Log.INFO, Log.ERROR -> true - else -> BuildConfig.DEBUG || prefs.verboseLogging - } - } - prefs.verboseLogging = false - if (prefs.installDate == -1L) { - prefs.installDate = System.currentTimeMillis() - } - if (prefs.identifier == -1) { - prefs.identifier = Random().nextInt(Int.MAX_VALUE) - } - prefs.lastLaunch = System.currentTimeMillis() + private fun initPrefs() { + prefs.deleteKeys("search_bar", "shown_release", "experimental_by_default") + KL.shouldLog = { BuildConfig.DEBUG } + L.shouldLog = { + when (it) { + Log.VERBOSE -> BuildConfig.DEBUG + Log.INFO, + Log.ERROR -> true + else -> BuildConfig.DEBUG || prefs.verboseLogging + } } + prefs.verboseLogging = false + if (prefs.installDate == -1L) { + prefs.installDate = System.currentTimeMillis() + } + if (prefs.identifier == -1) { + prefs.identifier = Random().nextInt(Int.MAX_VALUE) + } + prefs.lastLaunch = System.currentTimeMillis() + } } diff --git a/app/src/main/kotlin/com/pitchedapps/frost/StartActivity.kt b/app/src/main/kotlin/com/pitchedapps/frost/StartActivity.kt index 9a9529323..8a083c954 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/StartActivity.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/StartActivity.kt @@ -45,97 +45,90 @@ import com.pitchedapps.frost.utils.L import com.pitchedapps.frost.utils.launchNewTask import com.pitchedapps.frost.utils.loadAssets import dagger.hilt.android.AndroidEntryPoint -import kotlinx.coroutines.launch import java.util.ArrayList import javax.inject.Inject +import kotlinx.coroutines.launch -/** - * Created by Allan Wang on 2017-05-28. - */ +/** Created by Allan Wang on 2017-05-28. */ @AndroidEntryPoint class StartActivity : KauBaseActivity() { - @Inject - lateinit var fbCookie: FbCookie + @Inject lateinit var fbCookie: FbCookie - @Inject - lateinit var prefs: Prefs + @Inject lateinit var prefs: Prefs - @Inject - lateinit var themeProvider: ThemeProvider + @Inject lateinit var themeProvider: ThemeProvider - @Inject - lateinit var cookieDao: CookieDao + @Inject lateinit var cookieDao: CookieDao - @Inject - lateinit var genericDao: GenericDao + @Inject lateinit var genericDao: GenericDao - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) - if (!buildIsLollipopAndUp) { // not supported - showInvalidSdkView() - return - } + if (!buildIsLollipopAndUp) { // not supported + showInvalidSdkView() + return + } - try { - // TODO add better descriptions - CookieManager.getInstance() - } catch (e: Exception) { - L.e(e) { "No cookiemanager instance" } - showInvalidWebView() - } + try { + // TODO add better descriptions + CookieManager.getInstance() + } catch (e: Exception) { + L.e(e) { "No cookiemanager instance" } + showInvalidWebView() + } - launch { - try { - val authDefer = BiometricUtils.authenticate(this@StartActivity, prefs) - fbCookie.switchBackUser() - val cookies = ArrayList(cookieDao.selectAll()) - L.i { "Cookies loaded at time ${System.currentTimeMillis()}" } - L._d { - "Cookies: ${ + launch { + try { + val authDefer = BiometricUtils.authenticate(this@StartActivity, prefs) + fbCookie.switchBackUser() + val cookies = ArrayList(cookieDao.selectAll()) + L.i { "Cookies loaded at time ${System.currentTimeMillis()}" } + L._d { + "Cookies: ${ cookies.joinToString( "\t", transform = CookieEntity::toSensitiveString ) }" - } - loadAssets(themeProvider) - authDefer.await() - when { - cookies.isEmpty() -> launchNewTask() - // Has cookies but no selected account - prefs.userId == -1L -> launchNewTask(cookies) - else -> startActivity( - intentBuilder = { - putParcelableArrayListExtra(EXTRA_COOKIES, cookies) - flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP or - Intent.FLAG_ACTIVITY_SINGLE_TOP - } - ) - } - } catch (e: Exception) { - L._e(e) { "Load start failed" } - showInvalidWebView() - } } + loadAssets(themeProvider) + authDefer.await() + when { + cookies.isEmpty() -> launchNewTask() + // Has cookies but no selected account + prefs.userId == -1L -> launchNewTask(cookies) + else -> + startActivity( + intentBuilder = { + putParcelableArrayListExtra(EXTRA_COOKIES, cookies) + flags = + Intent.FLAG_ACTIVITY_NEW_TASK or + Intent.FLAG_ACTIVITY_CLEAR_TOP or + Intent.FLAG_ACTIVITY_SINGLE_TOP + } + ) + } + } catch (e: Exception) { + L._e(e) { "Load start failed" } + showInvalidWebView() + } } + } - private fun showInvalidWebView() = - showInvalidView(R.string.error_webview) + private fun showInvalidWebView() = showInvalidView(R.string.error_webview) - private fun showInvalidSdkView() { - val text = String.format(string(R.string.error_sdk), Build.VERSION.SDK_INT) - showInvalidView(text) - } + private fun showInvalidSdkView() { + val text = String.format(string(R.string.error_sdk), Build.VERSION.SDK_INT) + showInvalidView(text) + } - private fun showInvalidView(textRes: Int) = - showInvalidView(string(textRes)) + private fun showInvalidView(textRes: Int) = showInvalidView(string(textRes)) - private fun showInvalidView(text: String) { - setContentView(R.layout.activity_invalid) - findViewById(R.id.invalid_icon) - .setIcon(GoogleMaterial.Icon.gmd_adb, -1, Color.WHITE) - findViewById(R.id.invalid_text).text = text - } + private fun showInvalidView(text: String) { + setContentView(R.layout.activity_invalid) + findViewById(R.id.invalid_icon).setIcon(GoogleMaterial.Icon.gmd_adb, -1, Color.WHITE) + findViewById(R.id.invalid_text).text = text + } } diff --git a/app/src/main/kotlin/com/pitchedapps/frost/activities/AboutActivity.kt b/app/src/main/kotlin/com/pitchedapps/frost/activities/AboutActivity.kt index aec6d421c..c4f972368 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/activities/AboutActivity.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/activities/AboutActivity.kt @@ -52,154 +52,140 @@ import com.pitchedapps.frost.utils.L import dagger.hilt.android.AndroidEntryPoint import javax.inject.Inject -/** - * Created by Allan Wang on 2017-06-26. - */ +/** Created by Allan Wang on 2017-06-26. */ @AndroidEntryPoint class AboutActivity : AboutActivityBase(null) { - @Inject - lateinit var prefs: Prefs + @Inject lateinit var prefs: Prefs - @Inject - lateinit var themeProvider: ThemeProvider + @Inject lateinit var themeProvider: ThemeProvider - override fun Configs.buildConfigs() { - textColor = themeProvider.textColor - accentColor = themeProvider.accentColor - backgroundColor = themeProvider.bgColor.withMinAlpha(200) - cutoutForeground = themeProvider.accentColor - cutoutDrawableRes = R.drawable.frost_f_200 - faqPageTitleRes = R.string.faq_title - faqXmlRes = R.xml.frost_faq - faqParseNewLine = false + override fun Configs.buildConfigs() { + textColor = themeProvider.textColor + accentColor = themeProvider.accentColor + backgroundColor = themeProvider.bgColor.withMinAlpha(200) + cutoutForeground = themeProvider.accentColor + cutoutDrawableRes = R.drawable.frost_f_200 + faqPageTitleRes = R.string.faq_title + faqXmlRes = R.xml.frost_faq + faqParseNewLine = false + } + + var lastClick = -1L + var clickCount = 0 + + override fun postInflateMainPage(adapter: FastItemThemedAdapter) { + /** Frost may not be a library but we're conveying the same info */ + val frost = + Library( + uniqueId = "com.pitchedapps.frost", + name = string(R.string.frost_name), + developers = listOf(Developer(name = string(R.string.dev_name), organisationUrl = null)), + website = string(R.string.github_url), + description = string(R.string.frost_description), + artifactVersion = BuildConfig.VERSION_NAME, + licenses = + setOf( + License( + spdxId = "gplv3", + name = "GNU GPL v3", + url = "https://www.gnu.org/licenses/gpl-3.0.en.html", + hash = "gplv3" + ) + ), + scm = null, + organization = null, + ) + adapter.add(LibraryIItem(frost)).add(AboutLinks()) + adapter.onClickListener = { _, _, item, _ -> + if (item is LibraryIItem) { + val now = System.currentTimeMillis() + if (now - lastClick > 500) clickCount = 1 else clickCount++ + lastClick = now + if (clickCount == 8) { + if (!prefs.debugSettings) { + prefs.debugSettings = true + L.d { "Debugging section enabled" } + toast(R.string.debug_toast_enabled) + } else { + toast(R.string.debug_toast_already_enabled) + } + } + } + false + } + } + + class AboutLinks : + AbstractItem(), ThemableIItem by ThemableIItemDelegate() { + override fun getViewHolder(v: View): ViewHolder = ViewHolder(v) + + override val layoutRes: Int + get() = R.layout.item_about_links + + override val type: Int + get() = R.id.item_about_links + + override fun bindView(holder: ViewHolder, payloads: List) { + super.bindView(holder, payloads) + with(holder) { + bindIconColor(*images.toTypedArray()) + bindBackgroundColor(container) + } } - var lastClick = -1L - var clickCount = 0 + class ViewHolder(v: View) : RecyclerView.ViewHolder(v) { - override fun postInflateMainPage(adapter: FastItemThemedAdapter) { - /** - * Frost may not be a library but we're conveying the same info - */ - val frost = Library( - uniqueId = "com.pitchedapps.frost", - name = string(R.string.frost_name), - developers = listOf( - Developer(name = string(R.string.dev_name), organisationUrl = null) - ), - website = string(R.string.github_url), - description = string(R.string.frost_description), - artifactVersion = BuildConfig.VERSION_NAME, - licenses = setOf( - License( - spdxId = "gplv3", - name = "GNU GPL v3", - url = "https://www.gnu.org/licenses/gpl-3.0.en.html", - hash = "gplv3" - ) - ), - scm = null, - organization = null, + val container: ConstraintLayout by bindView(R.id.about_icons_container) + val images: List + + /** + * There are a lot of constraints to be added to each item just to have them chained properly + * My as well do it programmatically Initializing the viewholder will setup the icons, scale + * type and background of all icons, link their click listeners and chain them together via a + * horizontal spread + */ + init { + val c = itemView.context + val size = c.dimenPixelSize(R.dimen.kau_avatar_bounds) + + val icons: Array Unit>> = + arrayOf(R.drawable.ic_fdroid_24 to { c.startLink(R.string.fdroid_url) }) + val iicons: Array Unit>> = + arrayOf( + GoogleMaterial.Icon.gmd_file_download to { c.startLink(R.string.github_downloads_url) }, + CommunityMaterial.Icon3.cmd_reddit to { c.startLink(R.string.reddit_url) }, + CommunityMaterial.Icon2.cmd_github to { c.startLink(R.string.github_url) } + ) + + images = + (icons.map { (icon, onClick) -> c.drawable(icon) to onClick } + + iicons.map { (icon, onClick) -> icon.toDrawable(c, 32) to onClick }) + .mapIndexed { i, (icon, onClick) -> + ImageView(c).apply { + layoutParams = ViewGroup.LayoutParams(size, size) + id = 109389 + i + setImageDrawable(icon) + scaleType = ImageView.ScaleType.CENTER + background = + context.resolveDrawable(android.R.attr.selectableItemBackgroundBorderless) + setOnClickListener { onClick() } + container.addView(this) + } + } + val set = ConstraintSet() + set.clone(container) + set.createHorizontalChain( + ConstraintSet.PARENT_ID, + ConstraintSet.LEFT, + ConstraintSet.PARENT_ID, + ConstraintSet.RIGHT, + images.map { it.id }.toIntArray(), + null, + ConstraintSet.CHAIN_SPREAD_INSIDE ) - adapter.add(LibraryIItem(frost)).add(AboutLinks()) - adapter.onClickListener = { _, _, item, _ -> - if (item is LibraryIItem) { - val now = System.currentTimeMillis() - if (now - lastClick > 500) - clickCount = 1 - else - clickCount++ - lastClick = now - if (clickCount == 8) { - if (!prefs.debugSettings) { - prefs.debugSettings = true - L.d { "Debugging section enabled" } - toast(R.string.debug_toast_enabled) - } else { - toast(R.string.debug_toast_already_enabled) - } - } - } - false - } - } - - class AboutLinks : - AbstractItem(), - ThemableIItem by ThemableIItemDelegate() { - override fun getViewHolder(v: View): ViewHolder = ViewHolder(v) - - override val layoutRes: Int - get() = R.layout.item_about_links - - override val type: Int - get() = R.id.item_about_links - - override fun bindView(holder: ViewHolder, payloads: List) { - super.bindView(holder, payloads) - with(holder) { - bindIconColor(*images.toTypedArray()) - bindBackgroundColor(container) - } - } - - class ViewHolder(v: View) : RecyclerView.ViewHolder(v) { - - val container: ConstraintLayout by bindView(R.id.about_icons_container) - val images: List - - /** - * There are a lot of constraints to be added to each item just to have them chained properly - * My as well do it programmatically - * Initializing the viewholder will setup the icons, scale type and background of all icons, - * link their click listeners and chain them together via a horizontal spread - */ - init { - val c = itemView.context - val size = c.dimenPixelSize(R.dimen.kau_avatar_bounds) - - val icons: Array Unit>> = - arrayOf(R.drawable.ic_fdroid_24 to { c.startLink(R.string.fdroid_url) }) - val iicons: Array Unit>> = arrayOf( - GoogleMaterial.Icon.gmd_file_download to { c.startLink(R.string.github_downloads_url) }, - CommunityMaterial.Icon3.cmd_reddit to { c.startLink(R.string.reddit_url) }, - CommunityMaterial.Icon2.cmd_github to { c.startLink(R.string.github_url) } - ) - - images = - ( - icons.map { (icon, onClick) -> c.drawable(icon) to onClick } + iicons.map { (icon, onClick) -> - icon.toDrawable( - c, - 32 - ) to onClick - } - ).mapIndexed { i, (icon, onClick) -> - ImageView(c).apply { - layoutParams = ViewGroup.LayoutParams(size, size) - id = 109389 + i - setImageDrawable(icon) - scaleType = ImageView.ScaleType.CENTER - background = - context.resolveDrawable(android.R.attr.selectableItemBackgroundBorderless) - setOnClickListener { onClick() } - container.addView(this) - } - } - val set = ConstraintSet() - set.clone(container) - set.createHorizontalChain( - ConstraintSet.PARENT_ID, - ConstraintSet.LEFT, - ConstraintSet.PARENT_ID, - ConstraintSet.RIGHT, - images.map { it.id }.toIntArray(), - null, - ConstraintSet.CHAIN_SPREAD_INSIDE - ) - set.applyTo(container) - } - } + set.applyTo(container) + } } + } } diff --git a/app/src/main/kotlin/com/pitchedapps/frost/activities/BaseActivity.kt b/app/src/main/kotlin/com/pitchedapps/frost/activities/BaseActivity.kt index 36e44936d..6076207c3 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/activities/BaseActivity.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/activities/BaseActivity.kt @@ -28,48 +28,40 @@ import com.pitchedapps.frost.utils.ActivityThemer import dagger.hilt.android.AndroidEntryPoint import javax.inject.Inject -/** - * Created by Allan Wang on 2017-06-12. - */ +/** Created by Allan Wang on 2017-06-12. */ @AndroidEntryPoint abstract class BaseActivity : KauBaseActivity() { - @Inject - lateinit var fbCookie: FbCookie + @Inject lateinit var fbCookie: FbCookie - @Inject - lateinit var prefs: Prefs + @Inject lateinit var prefs: Prefs - @Inject - lateinit var themeProvider: ThemeProvider + @Inject lateinit var themeProvider: ThemeProvider - @Inject - lateinit var activityThemer: ActivityThemer + @Inject lateinit var activityThemer: ActivityThemer - /** - * Inherited consumer to customize back press - */ - protected open fun backConsumer(): Boolean = false + /** Inherited consumer to customize back press */ + protected open fun backConsumer(): Boolean = false - final override fun onBackPressed() { - if (this is SearchViewHolder && searchViewOnBackPress()) return - if (this is VideoViewHolder && videoOnBackPress()) return - if (backConsumer()) return - super.onBackPressed() - } + final override fun onBackPressed() { + if (this is SearchViewHolder && searchViewOnBackPress()) return + if (this is VideoViewHolder && videoOnBackPress()) return + if (backConsumer()) return + super.onBackPressed() + } - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - if (this !is WebOverlayActivityBase) activityThemer.setFrostTheme() - } + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + if (this !is WebOverlayActivityBase) activityThemer.setFrostTheme() + } - override fun onStop() { - if (this is VideoViewHolder) videoOnStop() - super.onStop() - } + override fun onStop() { + if (this is VideoViewHolder) videoOnStop() + super.onStop() + } - override fun onConfigurationChanged(newConfig: Configuration) { - super.onConfigurationChanged(newConfig) - if (this is VideoViewHolder) videoViewer?.updateLocation() - } + override fun onConfigurationChanged(newConfig: Configuration) { + super.onConfigurationChanged(newConfig) + if (this is VideoViewHolder) videoViewer?.updateLocation() + } } diff --git a/app/src/main/kotlin/com/pitchedapps/frost/activities/BaseMainActivity.kt b/app/src/main/kotlin/com/pitchedapps/frost/activities/BaseMainActivity.kt index 9d16c63ab..4484c8890 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/activities/BaseMainActivity.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/activities/BaseMainActivity.kt @@ -130,9 +130,9 @@ import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.components.ActivityComponent import dagger.hilt.android.qualifiers.ActivityContext import dagger.hilt.android.scopes.ActivityScoped -import kotlinx.coroutines.launch import javax.inject.Inject import kotlin.math.abs +import kotlinx.coroutines.launch /** * Created by Allan Wang on 20/12/17. @@ -141,803 +141,744 @@ import kotlin.math.abs */ @AndroidEntryPoint abstract class BaseMainActivity : - BaseActivity(), - MainActivityContract, - VideoViewHolder, - SearchViewHolder { + BaseActivity(), MainActivityContract, VideoViewHolder, SearchViewHolder { - /** - * Note that tabs themselves are initialized through a coroutine during onCreate - */ - protected val adapter: SectionsPagerAdapter = SectionsPagerAdapter() - override val frameWrapper: FrameLayout get() = drawerWrapperBinding.mainContainer - lateinit var drawerWrapperBinding: ActivityMainDrawerWrapperBinding - lateinit var contentBinding: ActivityMainContentBinding + /** Note that tabs themselves are initialized through a coroutine during onCreate */ + protected val adapter: SectionsPagerAdapter = SectionsPagerAdapter() + override val frameWrapper: FrameLayout + get() = drawerWrapperBinding.mainContainer + lateinit var drawerWrapperBinding: ActivityMainDrawerWrapperBinding + lateinit var contentBinding: ActivityMainContentBinding - @Inject - lateinit var cookieDao: CookieDao + @Inject lateinit var cookieDao: CookieDao - @Inject - lateinit var genericDao: GenericDao + @Inject lateinit var genericDao: GenericDao - @Inject - lateinit var webFileChooser: WebFileChooser + @Inject lateinit var webFileChooser: WebFileChooser - interface ActivityMainContentBinding { - val root: View - val toolbar: Toolbar - val viewpager: FrostViewPager - val tabs: TabLayout - val appbar: AppBarLayout - val fab: FloatingActionButton + interface ActivityMainContentBinding { + val root: View + val toolbar: Toolbar + val viewpager: FrostViewPager + val tabs: TabLayout + val appbar: AppBarLayout + val fab: FloatingActionButton + } + + protected var lastPosition = -1 + + override var videoViewer: FrostVideoViewer? = null + private var lastAccessTime = -1L + + override var searchView: SearchView? = null + private val searchViewCache = mutableMapOf>() + private var controlWebview: WebView? = null + + final override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + val start = System.currentTimeMillis() + drawerWrapperBinding = ActivityMainDrawerWrapperBinding.inflate(layoutInflater) + setContentView(drawerWrapperBinding.root) + contentBinding = + when (prefs.mainActivityLayout) { + MainActivityLayout.TOP_BAR -> { + val binding = ActivityMainBinding.inflate(layoutInflater) + @SuppressLint("StaticFieldLeak") + object : ActivityMainContentBinding { + override val root: View = binding.root + override val toolbar: Toolbar = binding.toolbar + override val viewpager: FrostViewPager = binding.viewpager + override val tabs: TabLayout = binding.tabs + override val appbar: AppBarLayout = binding.appbar + override val fab: FloatingActionButton = binding.fab + } + } + MainActivityLayout.BOTTOM_BAR -> { + val binding = ActivityMainBottomTabsBinding.inflate(layoutInflater) + @SuppressLint("StaticFieldLeak") + object : ActivityMainContentBinding { + override val root: View = binding.root + override val toolbar: Toolbar = binding.toolbar + override val viewpager: FrostViewPager = binding.viewpager + override val tabs: TabLayout = binding.tabs + override val appbar: AppBarLayout = binding.appbar + override val fab: FloatingActionButton = binding.fab + } + } + } + drawerWrapperBinding.mainContainer.addView(contentBinding.root) + with(contentBinding) { + activityThemer.setFrostColors { + toolbar(toolbar) + themeWindow = false + header(appbar) + background(viewpager) + } + setSupportActionBar(toolbar) + viewpager.adapter = adapter + tabs.setBackgroundColor(prefs.mainActivityLayout.backgroundColor(themeProvider)) } - - protected var lastPosition = -1 - - override var videoViewer: FrostVideoViewer? = null - private var lastAccessTime = -1L - - override var searchView: SearchView? = null - private val searchViewCache = mutableMapOf>() - private var controlWebview: WebView? = null - - final override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - val start = System.currentTimeMillis() - drawerWrapperBinding = ActivityMainDrawerWrapperBinding.inflate(layoutInflater) - setContentView(drawerWrapperBinding.root) - contentBinding = when (prefs.mainActivityLayout) { - MainActivityLayout.TOP_BAR -> { - val binding = ActivityMainBinding.inflate(layoutInflater) - @SuppressLint("StaticFieldLeak") - object : ActivityMainContentBinding { - override val root: View = binding.root - override val toolbar: Toolbar = binding.toolbar - override val viewpager: FrostViewPager = binding.viewpager - override val tabs: TabLayout = binding.tabs - override val appbar: AppBarLayout = binding.appbar - override val fab: FloatingActionButton = binding.fab - } - } - MainActivityLayout.BOTTOM_BAR -> { - val binding = ActivityMainBottomTabsBinding.inflate(layoutInflater) - @SuppressLint("StaticFieldLeak") - object : ActivityMainContentBinding { - override val root: View = binding.root - override val toolbar: Toolbar = binding.toolbar - override val viewpager: FrostViewPager = binding.viewpager - override val tabs: TabLayout = binding.tabs - override val appbar: AppBarLayout = binding.appbar - override val fab: FloatingActionButton = binding.fab - } - } - } - drawerWrapperBinding.mainContainer.addView(contentBinding.root) - with(contentBinding) { - activityThemer.setFrostColors { - toolbar(toolbar) - themeWindow = false - header(appbar) - background(viewpager) - } - setSupportActionBar(toolbar) - viewpager.adapter = adapter - tabs.setBackgroundColor(prefs.mainActivityLayout.backgroundColor(themeProvider)) - } - onNestedCreate(savedInstanceState) - L.i { "Main finished loading UI in ${System.currentTimeMillis() - start} ms" } - launch { - adapter.setPages(genericDao.getTabs()) - } - controlWebview = WebView(this) - if (BuildConfig.VERSION_CODE > prefs.versionCode) { - prefs.prevVersionCode = prefs.versionCode - prefs.versionCode = BuildConfig.VERSION_CODE - if (!BuildConfig.DEBUG) { - frostChangelog() - frostEvent( - "Version", - "Version code" to BuildConfig.VERSION_CODE, - "Prev version code" to prefs.prevVersionCode, - "Version name" to BuildConfig.VERSION_NAME, - "Build type" to BuildConfig.BUILD_TYPE, - "Frost id" to prefs.frostId - ) - } - } - L.i { "Main started in ${System.currentTimeMillis() - start} ms" } - drawerWrapperBinding.initDrawer() - contentBinding.initFab() - lastAccessTime = System.currentTimeMillis() - } - - /** - * Injector to handle creation for sub classes - */ - protected abstract fun onNestedCreate(savedInstanceState: Bundle?) - - private var hasFab = false - private var shouldShow = false - - private class FrostMenuBuilder(private val context: Context, private val menu: Menu) { - private var order: Int = 0 - private var groupId: Int = 13 - private val items: MutableList = mutableListOf() - - fun primaryFrostItem(fbItem: FbItem) { - val item = menu.add(groupId, fbItem.ordinal, order++, context.string(fbItem.titleId)) - item.icon = fbItem.icon.toDrawable(context, 18) - } - - fun divider() { - groupId++ - } - - fun secondaryFrostItem(fbItem: FbItem) { - menu.add(groupId, fbItem.ordinal, order++, context.string(fbItem.titleId)) - } - } - - private fun createNavDrawable(foreground: Int, background: Int): RippleDrawable { - val drawable = drawable(R.drawable.nav_item_background) as RippleDrawable - drawable.setColor( - ColorStateList( - arrayOf(intArrayOf()), - intArrayOf(background.blendWith(foreground.withAlpha(background.alpha), 0.35f)) - ) + onNestedCreate(savedInstanceState) + L.i { "Main finished loading UI in ${System.currentTimeMillis() - start} ms" } + launch { adapter.setPages(genericDao.getTabs()) } + controlWebview = WebView(this) + if (BuildConfig.VERSION_CODE > prefs.versionCode) { + prefs.prevVersionCode = prefs.versionCode + prefs.versionCode = BuildConfig.VERSION_CODE + if (!BuildConfig.DEBUG) { + frostChangelog() + frostEvent( + "Version", + "Version code" to BuildConfig.VERSION_CODE, + "Prev version code" to prefs.prevVersionCode, + "Version name" to BuildConfig.VERSION_NAME, + "Build type" to BuildConfig.BUILD_TYPE, + "Frost id" to prefs.frostId ) - return drawable + } + } + L.i { "Main started in ${System.currentTimeMillis() - start} ms" } + drawerWrapperBinding.initDrawer() + contentBinding.initFab() + lastAccessTime = System.currentTimeMillis() + } + + /** Injector to handle creation for sub classes */ + protected abstract fun onNestedCreate(savedInstanceState: Bundle?) + + private var hasFab = false + private var shouldShow = false + + private class FrostMenuBuilder(private val context: Context, private val menu: Menu) { + private var order: Int = 0 + private var groupId: Int = 13 + private val items: MutableList = mutableListOf() + + fun primaryFrostItem(fbItem: FbItem) { + val item = menu.add(groupId, fbItem.ordinal, order++, context.string(fbItem.titleId)) + item.icon = fbItem.icon.toDrawable(context, 18) } - private fun ActivityMainDrawerWrapperBinding.initDrawer() { + fun divider() { + groupId++ + } - val toggle = ActionBarDrawerToggle( - this@BaseMainActivity, drawer, contentBinding.toolbar, - R.string.open, - R.string.close + fun secondaryFrostItem(fbItem: FbItem) { + menu.add(groupId, fbItem.ordinal, order++, context.string(fbItem.titleId)) + } + } + + private fun createNavDrawable(foreground: Int, background: Int): RippleDrawable { + val drawable = drawable(R.drawable.nav_item_background) as RippleDrawable + drawable.setColor( + ColorStateList( + arrayOf(intArrayOf()), + intArrayOf(background.blendWith(foreground.withAlpha(background.alpha), 0.35f)) + ) + ) + return drawable + } + + private fun ActivityMainDrawerWrapperBinding.initDrawer() { + + val toggle = + ActionBarDrawerToggle( + this@BaseMainActivity, + drawer, + contentBinding.toolbar, + R.string.open, + R.string.close + ) + toggle.isDrawerSlideAnimationEnabled = false + drawer.addDrawerListener(toggle) + toggle.syncState() + + val foregroundColor = ColorStateList.valueOf(themeProvider.textColor) + + with(navigation) { + FrostMenuBuilder(this@BaseMainActivity, menu).apply { + primaryFrostItem(FbItem.FEED_MOST_RECENT) + primaryFrostItem(FbItem.FEED_TOP_STORIES) + primaryFrostItem(FbItem.ACTIVITY_LOG) + divider() + primaryFrostItem(FbItem.PHOTOS) + primaryFrostItem(FbItem.GROUPS) + primaryFrostItem(FbItem.FRIENDS) + primaryFrostItem(FbItem.CHAT) + primaryFrostItem(FbItem.PAGES) + divider() + primaryFrostItem(FbItem.EVENTS) + primaryFrostItem(FbItem.BIRTHDAYS) + primaryFrostItem(FbItem.ON_THIS_DAY) + divider() + primaryFrostItem(FbItem.NOTES) + primaryFrostItem(FbItem.SAVED) + primaryFrostItem(FbItem.MARKETPLACE) + } + setNavigationItemSelectedListener { + val item = FbItem.values[it.itemId] + frostEvent("Drawer Tab", "name" to item.name) + drawer.closeDrawer(navigation) + launchWebOverlay(item.url, fbCookie, prefs) + false + } + val navBg = themeProvider.bgColor.withMinAlpha(200) + setBackgroundColor(navBg) + itemBackground = createNavDrawable(themeProvider.accentColor, navBg) + itemTextColor = foregroundColor + itemIconTintList = foregroundColor + + val header = NavHeader() + addHeaderView(header.root) + } + } + + private fun ActivityMainContentBinding.initFab() { + hasFab = false + shouldShow = false + fab.backgroundTintList = ColorStateList.valueOf(themeProvider.headerColor.withMinAlpha(200)) + fab.hide() + appbar.addOnOffsetChangedListener( + AppBarLayout.OnOffsetChangedListener { appBarLayout, verticalOffset -> + if (!hasFab) return@OnOffsetChangedListener + val percent = abs(verticalOffset.toFloat() / appBarLayout.totalScrollRange) + val shouldShow = percent < 0.2 + if (this@BaseMainActivity.shouldShow != shouldShow) { + this@BaseMainActivity.shouldShow = shouldShow + fab.showIf(shouldShow) + } + } + ) + } + + override fun showFab(iicon: IIcon, clickEvent: () -> Unit) { + with(contentBinding) { + hasFab = true + fab.setOnClickListener { clickEvent() } + if (shouldShow) { + if (fab.isShown) { + fab.fadeScaleTransition { setIcon(iicon, color = themeProvider.iconColor) } + return + } + } + fab.setIcon(iicon, color = themeProvider.iconColor) + fab.showIf(shouldShow) + } + } + + override fun hideFab() { + with(contentBinding) { + hasFab = false + fab.setOnClickListener(null) + fab.hide() + } + } + + fun tabsForEachView(action: (position: Int, view: BadgedIcon) -> Unit) { + with(contentBinding) { + (0 until tabs.tabCount).asSequence().forEach { i -> + action(i, tabs.getTabAt(i)!!.customView as BadgedIcon) + } + } + } + + private inner class NavHeader { + + private var orderedAccounts: List = cookies() + private var pendingUpdate: Boolean = false + private val binding = ViewNavHeaderBinding.inflate(layoutInflater) + val root: View + get() = binding.root + private val optionsBackground = themeProvider.bgColor.withMinAlpha(200).colorToForeground(0.1f) + + init { + setPrimary(prefs.userId) + binding.updateAccounts() + with(drawerWrapperBinding) { + drawer.addDrawerListener( + object : DrawerLayout.SimpleDrawerListener() { + override fun onDrawerClosed(drawerView: View) { + if (drawerView !== navigation) return + if (!pendingUpdate) return + pendingUpdate = false + binding.updateAccounts() + } + } ) - toggle.isDrawerSlideAnimationEnabled = false - drawer.addDrawerListener(toggle) - toggle.syncState() - - val foregroundColor = ColorStateList.valueOf(themeProvider.textColor) - - with(navigation) { - FrostMenuBuilder(this@BaseMainActivity, menu).apply { - primaryFrostItem(FbItem.FEED_MOST_RECENT) - primaryFrostItem(FbItem.FEED_TOP_STORIES) - primaryFrostItem(FbItem.ACTIVITY_LOG) - divider() - primaryFrostItem(FbItem.PHOTOS) - primaryFrostItem(FbItem.GROUPS) - primaryFrostItem(FbItem.FRIENDS) - primaryFrostItem(FbItem.CHAT) - primaryFrostItem(FbItem.PAGES) - divider() - primaryFrostItem(FbItem.EVENTS) - primaryFrostItem(FbItem.BIRTHDAYS) - primaryFrostItem(FbItem.ON_THIS_DAY) - divider() - primaryFrostItem(FbItem.NOTES) - primaryFrostItem(FbItem.SAVED) - primaryFrostItem(FbItem.MARKETPLACE) + } + with(binding) { + optionsContainer.setBackgroundColor(optionsBackground) + var showOptions = false + val animator: ProgressAnimator = ProgressAnimator.ofFloat() + background.setOnClickListener { + animator.reset() + if (showOptions) { + animator.apply { + withAnimator(optionsContainer.height, 0) { + optionsContainer.updateLayoutParams { height = it } + } + withAnimator(arrow.rotation, 0f) { arrow.rotation = it } + withEndAction { optionsContainer.gone() } } - setNavigationItemSelectedListener { - val item = FbItem.values[it.itemId] - frostEvent("Drawer Tab", "name" to item.name) - drawer.closeDrawer(navigation) - launchWebOverlay(item.url, fbCookie, prefs) - false - } - val navBg = themeProvider.bgColor.withMinAlpha(200) - setBackgroundColor(navBg) - itemBackground = createNavDrawable(themeProvider.accentColor, navBg) - itemTextColor = foregroundColor - itemIconTintList = foregroundColor - - val header = NavHeader() - addHeaderView(header.root) - } - } - - private fun ActivityMainContentBinding.initFab() { - hasFab = false - shouldShow = false - fab.backgroundTintList = ColorStateList.valueOf(themeProvider.headerColor.withMinAlpha(200)) - fab.hide() - appbar.addOnOffsetChangedListener( - AppBarLayout.OnOffsetChangedListener { appBarLayout, verticalOffset -> - if (!hasFab) return@OnOffsetChangedListener - val percent = abs(verticalOffset.toFloat() / appBarLayout.totalScrollRange) - val shouldShow = percent < 0.2 - if (this@BaseMainActivity.shouldShow != shouldShow) { - this@BaseMainActivity.shouldShow = shouldShow - fab.showIf(shouldShow) - } - } - ) - } - - override fun showFab(iicon: IIcon, clickEvent: () -> Unit) { - with(contentBinding) { - hasFab = true - fab.setOnClickListener { clickEvent() } - if (shouldShow) { - if (fab.isShown) { - fab.fadeScaleTransition { - setIcon(iicon, color = themeProvider.iconColor) - } - return - } - } - fab.setIcon(iicon, color = themeProvider.iconColor) - fab.showIf(shouldShow) - } - } - - override fun hideFab() { - with(contentBinding) { - hasFab = false - fab.setOnClickListener(null) - fab.hide() - } - } - - fun tabsForEachView(action: (position: Int, view: BadgedIcon) -> Unit) { - with(contentBinding) { - (0 until tabs.tabCount).asSequence().forEach { i -> - action(i, tabs.getTabAt(i)!!.customView as BadgedIcon) - } - } - } - - private inner class NavHeader { - - private var orderedAccounts: List = cookies() - private var pendingUpdate: Boolean = false - private val binding = ViewNavHeaderBinding.inflate(layoutInflater) - val root: View get() = binding.root - private val optionsBackground = themeProvider.bgColor.withMinAlpha(200).colorToForeground( - 0.1f - ) - - init { - setPrimary(prefs.userId) - binding.updateAccounts() - with(drawerWrapperBinding) { - drawer.addDrawerListener(object : DrawerLayout.SimpleDrawerListener() { - override fun onDrawerClosed(drawerView: View) { - if (drawerView !== navigation) return - if (!pendingUpdate) return - pendingUpdate = false - binding.updateAccounts() - } - }) - } - with(binding) { - optionsContainer.setBackgroundColor(optionsBackground) - var showOptions = false - val animator: ProgressAnimator = ProgressAnimator.ofFloat() - background.setOnClickListener { - animator.reset() - if (showOptions) { - animator.apply { - withAnimator(optionsContainer.height, 0) { - optionsContainer.updateLayoutParams { - height = it - } - } - withAnimator(arrow.rotation, 0f) { - arrow.rotation = it - } - withEndAction { - optionsContainer.gone() - } - } - } else { - optionsContainer.visible() - animator.apply { - withAnimator( - optionsContainer.height, - optionsContainer.unboundedHeight - ) { - optionsContainer.updateLayoutParams { - height = it - } - } - withEndAction { - // Sometimes, height remains the same as measured during collapse - // if the animations are disabled. - // We will resolve this by always falling back to wrap content afterwards - optionsContainer.updateLayoutParams { - height = ViewGroup.LayoutParams.WRAP_CONTENT - } - } - withAnimator(arrow.rotation, 180f) { - arrow.rotation = it - } - } - } - showOptions = !showOptions - animator.start() - } - - val textColor = themeProvider.textColor - - fun TextView.setOptionsIcon(iicon: IIcon) { - setCompoundDrawablesRelativeWithIntrinsicBounds( - iicon.toDrawable(this@BaseMainActivity, color = textColor, sizeDp = 20), - null, - null, - null - ) - setTextColor(textColor) - background = createNavDrawable(themeProvider.accentColor, optionsBackground) - } - - with(optionsLogout) { - setOptionsIcon(GoogleMaterial.Icon.gmd_exit_to_app) - setOnClickListener { - launch { - val currentCookie = cookieDao.currentCookie(prefs) - if (currentCookie == null) { - toast(R.string.account_not_found) - fbCookie.reset() - launchLogin(cookies(), true) - } else { - materialDialog { - title(R.string.kau_logout) - message( - text = - String.format( - string(R.string.kau_logout_confirm_as_x), - currentCookie.name ?: prefs.userId.toString() - ) - ) - positiveButton(R.string.kau_yes) { - this@BaseMainActivity.launch { - fbCookie.logout( - this@BaseMainActivity, - deleteCookie = true - ) - } - } - negativeButton(R.string.kau_no) - } - } - } - } - } - with(optionsAddAccount) { - setOptionsIcon(GoogleMaterial.Icon.gmd_add) - setOnClickListener { - launchNewTask(clearStack = false) - } - } - with(optionsManageAccount) { - setOptionsIcon(GoogleMaterial.Icon.gmd_settings) - setOnClickListener { - launchNewTask(cookies(), false) - } - } - arrow.setImageDrawable( - GoogleMaterial.Icon.gmd_arrow_drop_down.toDrawable( - this@BaseMainActivity, - color = themeProvider.textColor - ) - ) + } else { + optionsContainer.visible() + animator.apply { + withAnimator(optionsContainer.height, optionsContainer.unboundedHeight) { + optionsContainer.updateLayoutParams { height = it } + } + withEndAction { + // Sometimes, height remains the same as measured during collapse + // if the animations are disabled. + // We will resolve this by always falling back to wrap content afterwards + optionsContainer.updateLayoutParams { height = ViewGroup.LayoutParams.WRAP_CONTENT } + } + withAnimator(arrow.rotation, 180f) { arrow.rotation = it } } + } + showOptions = !showOptions + animator.start() } - private fun setPrimary(id: Long) { - val (primaries, others) = orderedAccounts.partition { it.id == id } - if (primaries.size != 1) { - L._e(null) { "Updating account primaries, could not find specified id" } - } - orderedAccounts = primaries + others + val textColor = themeProvider.textColor + + fun TextView.setOptionsIcon(iicon: IIcon) { + setCompoundDrawablesRelativeWithIntrinsicBounds( + iicon.toDrawable(this@BaseMainActivity, color = textColor, sizeDp = 20), + null, + null, + null + ) + setTextColor(textColor) + background = createNavDrawable(themeProvider.accentColor, optionsBackground) } - /** - * Syncs UI to match [orderedAccounts]. - * - * We keep this separate as we usually only want to update when the drawer is hidden. - */ - private fun ViewNavHeaderBinding.updateAccounts() { - avatarPrimary.setAccount(orderedAccounts.getOrNull(0), true) - avatarSecondary.setAccount(orderedAccounts.getOrNull(1), false) - avatarTertiary.setAccount(orderedAccounts.getOrNull(2), false) - optionsAccountsContainer.removeAllViews() - name.text = orderedAccounts.getOrNull(0)?.name - name.setTextColor(themeProvider.textColor) - val glide = Glide.with(root) - val accountSize = dimenPixelSize(R.dimen.drawer_account_avatar_size) - val textColor = themeProvider.textColor - orderedAccounts.forEach { cookie -> - val tv = - TextView( - this@BaseMainActivity, - null, - 0, - R.style.Main_DrawerAccountUserOptions - ) - glide.load(profilePictureUrl(cookie.id)).transform(FrostGlide.circleCrop) - .into(object : CustomTarget(accountSize, accountSize) { - override fun onLoadCleared(placeholder: Drawable?) { - tv.setCompoundDrawablesRelativeWithIntrinsicBounds( - placeholder, - null, - null, - null - ) - } - - override fun onResourceReady( - resource: Drawable, - transition: Transition? - ) { - tv.setCompoundDrawablesRelativeWithIntrinsicBounds( - resource, - null, - null, - null - ) - } - }) - tv.text = cookie.name - tv.setTextColor(textColor) - tv.background = createNavDrawable(themeProvider.accentColor, optionsBackground) - tv.setOnClickListener { - switchAccount(cookie.id) - } - optionsAccountsContainer.addView(tv) - } - } - - private fun closeDrawer() { - with(drawerWrapperBinding) { - drawer.closeDrawer(navigation) - } - } - - private fun ImageView.setAccount( - cookie: CookieEntity?, - primary: Boolean - ) { - if (cookie == null) { - invisible() - setOnClickListener(null) - } else { - visible() - GlideApp.with(this) - .load(profilePictureUrl(cookie.id)) - .transform(FrostGlide.circleCrop) - .into(this) - setOnClickListener { - if (primary) { - launchWebOverlay(FbItem.PROFILE.url, fbCookie, prefs) - } else { - switchAccount(cookie.id) - } - closeDrawer() - } - } - } - - private fun switchAccount(id: Long) { - if (prefs.userId == id) return - setPrimary(id) - pendingUpdate = true - closeDrawer() + with(optionsLogout) { + setOptionsIcon(GoogleMaterial.Icon.gmd_exit_to_app) + setOnClickListener { launch { - fbCookie.switchUser(id) - tabsForEachView { _, view -> view.badgeText = null } - refreshAll() + val currentCookie = cookieDao.currentCookie(prefs) + if (currentCookie == null) { + toast(R.string.account_not_found) + fbCookie.reset() + launchLogin(cookies(), true) + } else { + materialDialog { + title(R.string.kau_logout) + message( + text = + String.format( + string(R.string.kau_logout_confirm_as_x), + currentCookie.name ?: prefs.userId.toString() + ) + ) + positiveButton(R.string.kau_yes) { + this@BaseMainActivity.launch { + fbCookie.logout(this@BaseMainActivity, deleteCookie = true) + } + } + negativeButton(R.string.kau_no) + } + } } + } } - } - - private fun refreshAll() { - L.d { "Refresh all" } - fragmentEmit(REQUEST_REFRESH) - } - - override fun onCreateOptionsMenu(menu: Menu): Boolean { - menuInflater.inflate(R.menu.menu_main, menu) - contentBinding.toolbar.tint(themeProvider.iconColor) - setMenuIcons( - menu, themeProvider.iconColor, - R.id.action_settings to GoogleMaterial.Icon.gmd_settings, - R.id.action_search to GoogleMaterial.Icon.gmd_search + with(optionsAddAccount) { + setOptionsIcon(GoogleMaterial.Icon.gmd_add) + setOnClickListener { launchNewTask(clearStack = false) } + } + with(optionsManageAccount) { + setOptionsIcon(GoogleMaterial.Icon.gmd_settings) + setOnClickListener { launchNewTask(cookies(), false) } + } + arrow.setImageDrawable( + GoogleMaterial.Icon.gmd_arrow_drop_down.toDrawable( + this@BaseMainActivity, + color = themeProvider.textColor + ) ) - bindSearchView(menu) + } + } + + private fun setPrimary(id: Long) { + val (primaries, others) = orderedAccounts.partition { it.id == id } + if (primaries.size != 1) { + L._e(null) { "Updating account primaries, could not find specified id" } + } + orderedAccounts = primaries + others + } + + /** + * Syncs UI to match [orderedAccounts]. + * + * We keep this separate as we usually only want to update when the drawer is hidden. + */ + private fun ViewNavHeaderBinding.updateAccounts() { + avatarPrimary.setAccount(orderedAccounts.getOrNull(0), true) + avatarSecondary.setAccount(orderedAccounts.getOrNull(1), false) + avatarTertiary.setAccount(orderedAccounts.getOrNull(2), false) + optionsAccountsContainer.removeAllViews() + name.text = orderedAccounts.getOrNull(0)?.name + name.setTextColor(themeProvider.textColor) + val glide = Glide.with(root) + val accountSize = dimenPixelSize(R.dimen.drawer_account_avatar_size) + val textColor = themeProvider.textColor + orderedAccounts.forEach { cookie -> + val tv = TextView(this@BaseMainActivity, null, 0, R.style.Main_DrawerAccountUserOptions) + glide + .load(profilePictureUrl(cookie.id)) + .transform(FrostGlide.circleCrop) + .into( + object : CustomTarget(accountSize, accountSize) { + override fun onLoadCleared(placeholder: Drawable?) { + tv.setCompoundDrawablesRelativeWithIntrinsicBounds(placeholder, null, null, null) + } + + override fun onResourceReady( + resource: Drawable, + transition: Transition? + ) { + tv.setCompoundDrawablesRelativeWithIntrinsicBounds(resource, null, null, null) + } + } + ) + tv.text = cookie.name + tv.setTextColor(textColor) + tv.background = createNavDrawable(themeProvider.accentColor, optionsBackground) + tv.setOnClickListener { switchAccount(cookie.id) } + optionsAccountsContainer.addView(tv) + } + } + + private fun closeDrawer() { + with(drawerWrapperBinding) { drawer.closeDrawer(navigation) } + } + + private fun ImageView.setAccount(cookie: CookieEntity?, primary: Boolean) { + if (cookie == null) { + invisible() + setOnClickListener(null) + } else { + visible() + GlideApp.with(this) + .load(profilePictureUrl(cookie.id)) + .transform(FrostGlide.circleCrop) + .into(this) + setOnClickListener { + if (primary) { + launchWebOverlay(FbItem.PROFILE.url, fbCookie, prefs) + } else { + switchAccount(cookie.id) + } + closeDrawer() + } + } + } + + private fun switchAccount(id: Long) { + if (prefs.userId == id) return + setPrimary(id) + pendingUpdate = true + closeDrawer() + launch { + fbCookie.switchUser(id) + tabsForEachView { _, view -> view.badgeText = null } + refreshAll() + } + } + } + + private fun refreshAll() { + L.d { "Refresh all" } + fragmentEmit(REQUEST_REFRESH) + } + + override fun onCreateOptionsMenu(menu: Menu): Boolean { + menuInflater.inflate(R.menu.menu_main, menu) + contentBinding.toolbar.tint(themeProvider.iconColor) + setMenuIcons( + menu, + themeProvider.iconColor, + R.id.action_settings to GoogleMaterial.Icon.gmd_settings, + R.id.action_search to GoogleMaterial.Icon.gmd_search + ) + bindSearchView(menu) + return true + } + + private fun bindSearchView(menu: Menu) { + searchViewBindIfNull { + bindSearchView(menu, R.id.action_search, themeProvider.iconColor) { + textCallback = { query, searchView -> + val results = searchViewCache[query] + if (results != null) searchView.results = results + else { + val data = SearchParser.query(fbCookie.webCookie, query)?.data?.results + if (data != null) { + val items = data.mapTo(mutableListOf(), FrostSearch::toSearchItem) + if (items.isNotEmpty()) + items.add( + SearchItem( + "${FbItem._SEARCH.url}/?q=${query.urlEncode()}", + string(R.string.show_all_results), + iicon = null + ) + ) + searchViewCache[query] = items + + searchView.results = items + } + } + } + textDebounceInterval = 300 + searchCallback = { query, _ -> + launchWebOverlay("${FbItem._SEARCH.url}/?q=${query.urlEncode()}", fbCookie, prefs) + true + } + closeListener = { _ -> searchViewCache.clear() } + foregroundColor = themeProvider.textColor + backgroundColor = themeProvider.bgColor.withMinAlpha(200) + onItemClick = { _, key, _, _ -> launchWebOverlay(key, fbCookie, prefs) } + } + } + } + + @SuppressLint("RestrictedApi") + override fun onOptionsItemSelected(item: MenuItem): Boolean { + when (item.itemId) { + R.id.action_settings -> { + val intent = Intent(this, SettingsActivity::class.java) + intent.putParcelableArrayListExtra(EXTRA_COOKIES, cookies()) + val bundle = + ActivityOptions.makeCustomAnimation(this, R.anim.kau_slide_in_right, R.anim.kau_fade_out) + .toBundle() + startActivityForResult(intent, ACTIVITY_SETTINGS, bundle) + } + else -> return super.onOptionsItemSelected(item) + } + return true + } + + @SuppressLint("NewApi") + override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { + if (webFileChooser.onActivityResultWeb(requestCode, resultCode, data)) return + super.onActivityResult(requestCode, resultCode, data) + + fun hasRequest(flag: Int) = resultCode and flag > 0 + + if (requestCode == ACTIVITY_SETTINGS) { + if (resultCode and REQUEST_RESTART_APPLICATION > 0) { // completely restart application + L.d { "Restart Application Requested" } + val intent = packageManager.getLaunchIntentForPackage(packageName)!! + Intent.makeRestartActivityTask(intent.component) + Runtime.getRuntime().exit(0) + return + } + if (resultCode and REQUEST_RESTART > 0) { + NotificationWidget.forceUpdate(this) + restart() + return + } + /* + * These results can be stacked + */ + if (hasRequest(REQUEST_REFRESH)) { + fragmentEmit(REQUEST_REFRESH) + } + if (hasRequest(REQUEST_NAV)) { + frostNavigationBar(prefs, themeProvider) + } + if (hasRequest(REQUEST_TEXT_ZOOM)) { + fragmentEmit(REQUEST_TEXT_ZOOM) + } + if (hasRequest(REQUEST_SEARCH)) { + invalidateOptionsMenu() + } + if (hasRequest(REQUEST_FAB)) { + fragmentEmit(lastPosition) + } + if (hasRequest(REQUEST_NOTIFICATION)) { + scheduleNotificationsFromPrefs(prefs) + } + } + } + + override fun onSaveInstanceState(outState: Bundle) { + super.onSaveInstanceState(outState) + adapter.saveInstanceState(outState) + } + + override fun onRestoreInstanceState(savedInstanceState: Bundle) { + super.onRestoreInstanceState(savedInstanceState) + adapter.restoreInstanceState(savedInstanceState) + } + + override fun onResume() { + super.onResume() + val shouldReload = System.currentTimeMillis() - lastAccessTime > MAIN_TIMEOUT_DURATION + lastAccessTime = System.currentTimeMillis() // precaution to avoid loops + controlWebview?.resumeTimers() + launch { + val authDefer = BiometricUtils.authenticate(this@BaseMainActivity, prefs) + fbCookie.switchBackUser() + authDefer.await() + if (shouldReload && prefs.autoRefreshFeed) { + refreshAll() + } + } + } + + override fun onPause() { + controlWebview?.pauseTimers() + L.v { "Pause main web timers" } + lastAccessTime = System.currentTimeMillis() + super.onPause() + } + + override fun onDestroy() { + controlWebview?.destroy() + super.onDestroy() + } + + override fun collapseAppBar() { + with(contentBinding) { appbar.post { appbar.setExpanded(false) } } + } + + override fun backConsumer(): Boolean { + with(drawerWrapperBinding) { + if (drawer.isDrawerOpen(navigation)) { + drawer.closeDrawer(navigation) return true + } + } + if (currentFragment?.onBackPressed() == true) return true + if (prefs.exitConfirmation) { + materialDialog { + title(R.string.kau_exit) + message(R.string.kau_exit_confirmation) + positiveButton(R.string.kau_yes) { finish() } + negativeButton(R.string.kau_no) + checkBoxPrompt(R.string.kau_do_not_show_again, isCheckedDefault = false) { + prefs.exitConfirmation = !it + } + } + return true + } + return false + } + + inline val currentFragment: BaseFragment? + get() { + val viewpager = contentBinding.viewpager + return supportFragmentManager.findFragmentByTag( + "android:switcher:${viewpager.id}:${viewpager.currentItem}" + ) as BaseFragment? } - private fun bindSearchView(menu: Menu) { - searchViewBindIfNull { - bindSearchView(menu, R.id.action_search, themeProvider.iconColor) { - textCallback = { query, searchView -> - val results = searchViewCache[query] - if (results != null) - searchView.results = results - else { - val data = SearchParser.query(fbCookie.webCookie, query)?.data?.results - if (data != null) { - val items = data.mapTo(mutableListOf(), FrostSearch::toSearchItem) - if (items.isNotEmpty()) - items.add( - SearchItem( - "${FbItem._SEARCH.url}/?q=${query.urlEncode()}", - string(R.string.show_all_results), - iicon = null - ) - ) - searchViewCache[query] = items + override fun reloadFragment(fragment: BaseFragment) { + runOnUiThread { adapter.reloadFragment(fragment) } + } - searchView.results = items - } - } - } - textDebounceInterval = 300 - searchCallback = - { query, _ -> - launchWebOverlay( - "${FbItem._SEARCH.url}/?q=${query.urlEncode()}", - fbCookie, - prefs - ); true - } - closeListener = { _ -> searchViewCache.clear() } - foregroundColor = themeProvider.textColor - backgroundColor = themeProvider.bgColor.withMinAlpha(200) - onItemClick = { _, key, _, _ -> launchWebOverlay(key, fbCookie, prefs) } - } + inner class SectionsPagerAdapter : FragmentPagerAdapter(supportFragmentManager) { + + private val pages: MutableList = mutableListOf() + + private val forcedFallbacks = mutableSetOf() + + /** Update page list and prompt reload */ + fun setPages(pages: List) { + this.pages.clear() + this.pages.addAll(pages) + notifyDataSetChanged() + with(contentBinding) { + tabs.removeAllTabs() + this@SectionsPagerAdapter.pages.forEachIndexed { index, fbItem -> + tabs.addTab( + tabs + .newTab() + .setCustomView( + BadgedIcon(this@BaseMainActivity) + .apply { iicon = fbItem.icon } + .also { + it.setAllAlpha(if (index == 0) SELECTED_TAB_ALPHA else UNSELECTED_TAB_ALPHA) + } + ) + ) } + lastPosition = 0 + viewpager.setCurrentItem(0, false) + viewpager.offscreenPageLimit = pages.size + // todo check if post is necessary + viewpager.post { fragmentEmit(0) } // trigger hook so title is set + } } - @SuppressLint("RestrictedApi") - override fun onOptionsItemSelected(item: MenuItem): Boolean { - when (item.itemId) { - R.id.action_settings -> { - val intent = Intent(this, SettingsActivity::class.java) - intent.putParcelableArrayListExtra(EXTRA_COOKIES, cookies()) - val bundle = - ActivityOptions.makeCustomAnimation( - this, - R.anim.kau_slide_in_right, - R.anim.kau_fade_out - ).toBundle() - startActivityForResult(intent, ACTIVITY_SETTINGS, bundle) - } - else -> return super.onOptionsItemSelected(item) - } - return true + fun saveInstanceState(outState: Bundle) { + outState.putStringArrayList(STATE_FORCE_FALLBACK, ArrayList(forcedFallbacks)) } - @SuppressLint("NewApi") - override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { - if (webFileChooser.onActivityResultWeb(requestCode, resultCode, data)) return - super.onActivityResult(requestCode, resultCode, data) - - fun hasRequest(flag: Int) = resultCode and flag > 0 - - if (requestCode == ACTIVITY_SETTINGS) { - if (resultCode and REQUEST_RESTART_APPLICATION > 0) { // completely restart application - L.d { "Restart Application Requested" } - val intent = packageManager.getLaunchIntentForPackage(packageName)!! - Intent.makeRestartActivityTask(intent.component) - Runtime.getRuntime().exit(0) - return - } - if (resultCode and REQUEST_RESTART > 0) { - NotificationWidget.forceUpdate(this) - restart() - return - } - /* - * These results can be stacked - */ - if (hasRequest(REQUEST_REFRESH)) { - fragmentEmit(REQUEST_REFRESH) - } - if (hasRequest(REQUEST_NAV)) { - frostNavigationBar(prefs, themeProvider) - } - if (hasRequest(REQUEST_TEXT_ZOOM)) { - fragmentEmit(REQUEST_TEXT_ZOOM) - } - if (hasRequest(REQUEST_SEARCH)) { - invalidateOptionsMenu() - } - if (hasRequest(REQUEST_FAB)) { - fragmentEmit(lastPosition) - } - if (hasRequest(REQUEST_NOTIFICATION)) { - scheduleNotificationsFromPrefs(prefs) - } - } + fun restoreInstanceState(savedInstanceState: Bundle) { + forcedFallbacks.clear() + forcedFallbacks.addAll( + savedInstanceState.getStringArrayList(STATE_FORCE_FALLBACK) ?: emptyList() + ) } - override fun onSaveInstanceState(outState: Bundle) { - super.onSaveInstanceState(outState) - adapter.saveInstanceState(outState) + fun reloadFragment(fragment: BaseFragment) { + if (fragment is WebFragment) return + L.d { "Reload fragment ${fragment.position}: ${fragment.baseEnum.name}" } + forcedFallbacks.add(fragment.baseEnum.name) + supportFragmentManager.beginTransaction().remove(fragment).commitNowAllowingStateLoss() + notifyDataSetChanged() } - override fun onRestoreInstanceState(savedInstanceState: Bundle) { - super.onRestoreInstanceState(savedInstanceState) - adapter.restoreInstanceState(savedInstanceState) + override fun getItem(position: Int): Fragment { + val item = pages[position] + return BaseFragment( + item.fragmentCreator, + prefs, + forcedFallbacks.contains(item.name), + item, + position + ) } - override fun onResume() { - super.onResume() - val shouldReload = System.currentTimeMillis() - lastAccessTime > MAIN_TIMEOUT_DURATION - lastAccessTime = System.currentTimeMillis() // precaution to avoid loops - controlWebview?.resumeTimers() - launch { - val authDefer = BiometricUtils.authenticate(this@BaseMainActivity, prefs) - fbCookie.switchBackUser() - authDefer.await() - if (shouldReload && prefs.autoRefreshFeed) { - refreshAll() - } - } + override fun getCount() = pages.size + + override fun getPageTitle(position: Int): CharSequence = getString(pages[position].titleId) + + override fun getItemPosition(fragment: Any) = + when { + fragment !is BaseFragment -> POSITION_UNCHANGED + fragment is WebFragment || fragment.valid -> POSITION_UNCHANGED + else -> POSITION_NONE + } + } + + private val lowerVideoPaddingPointF = PointF() + + override val lowerVideoPadding: PointF + get() { + if (prefs.mainActivityLayout == MainActivityLayout.BOTTOM_BAR) + lowerVideoPaddingPointF.set(0f, contentBinding.toolbar.height.toFloat()) + else lowerVideoPaddingPointF.set(0f, 0f) + return lowerVideoPaddingPointF } - override fun onPause() { - controlWebview?.pauseTimers() - L.v { "Pause main web timers" } - lastAccessTime = System.currentTimeMillis() - super.onPause() - } - - override fun onDestroy() { - controlWebview?.destroy() - super.onDestroy() - } - - override fun collapseAppBar() { - with(contentBinding) { - appbar.post { appbar.setExpanded(false) } - } - } - - override fun backConsumer(): Boolean { - with(drawerWrapperBinding) { - if (drawer.isDrawerOpen(navigation)) { - drawer.closeDrawer(navigation) - return true - } - } - if (currentFragment?.onBackPressed() == true) return true - if (prefs.exitConfirmation) { - materialDialog { - title(R.string.kau_exit) - message(R.string.kau_exit_confirmation) - positiveButton(R.string.kau_yes) { finish() } - negativeButton(R.string.kau_no) - checkBoxPrompt(R.string.kau_do_not_show_again, isCheckedDefault = false) { - prefs.exitConfirmation = !it - } - } - return true - } - return false - } - - inline val currentFragment: BaseFragment? - get() { - val viewpager = contentBinding.viewpager - return supportFragmentManager.findFragmentByTag("android:switcher:${viewpager.id}:${viewpager.currentItem}") as BaseFragment? - } - - override fun reloadFragment(fragment: BaseFragment) { - runOnUiThread { adapter.reloadFragment(fragment) } - } - - inner class SectionsPagerAdapter : FragmentPagerAdapter(supportFragmentManager) { - - private val pages: MutableList = mutableListOf() - - private val forcedFallbacks = mutableSetOf() - - /** - * Update page list and prompt reload - */ - fun setPages(pages: List) { - this.pages.clear() - this.pages.addAll(pages) - notifyDataSetChanged() - with(contentBinding) { - tabs.removeAllTabs() - this@SectionsPagerAdapter.pages.forEachIndexed { index, fbItem -> - tabs.addTab( - tabs.newTab() - .setCustomView( - BadgedIcon(this@BaseMainActivity).apply { - iicon = fbItem.icon - }.also { - it.setAllAlpha(if (index == 0) SELECTED_TAB_ALPHA else UNSELECTED_TAB_ALPHA) - } - ) - ) - } - lastPosition = 0 - viewpager.setCurrentItem(0, false) - viewpager.offscreenPageLimit = pages.size - // todo check if post is necessary - viewpager.post { - fragmentEmit(0) - } // trigger hook so title is set - } - } - - fun saveInstanceState(outState: Bundle) { - outState.putStringArrayList(STATE_FORCE_FALLBACK, ArrayList(forcedFallbacks)) - } - - fun restoreInstanceState(savedInstanceState: Bundle) { - forcedFallbacks.clear() - forcedFallbacks.addAll( - savedInstanceState.getStringArrayList(STATE_FORCE_FALLBACK) - ?: emptyList() - ) - } - - fun reloadFragment(fragment: BaseFragment) { - if (fragment is WebFragment) return - L.d { "Reload fragment ${fragment.position}: ${fragment.baseEnum.name}" } - forcedFallbacks.add(fragment.baseEnum.name) - supportFragmentManager.beginTransaction().remove(fragment).commitNowAllowingStateLoss() - notifyDataSetChanged() - } - - override fun getItem(position: Int): Fragment { - val item = pages[position] - return BaseFragment( - item.fragmentCreator, - prefs, - forcedFallbacks.contains(item.name), - item, - position - ) - } - - override fun getCount() = pages.size - - override fun getPageTitle(position: Int): CharSequence = getString(pages[position].titleId) - - override fun getItemPosition(fragment: Any) = - when { - fragment !is BaseFragment -> POSITION_UNCHANGED - fragment is WebFragment || fragment.valid -> POSITION_UNCHANGED - else -> POSITION_NONE - } - } - - private val lowerVideoPaddingPointF = PointF() - - override val lowerVideoPadding: PointF - get() { - if (prefs.mainActivityLayout == MainActivityLayout.BOTTOM_BAR) - lowerVideoPaddingPointF.set(0f, contentBinding.toolbar.height.toFloat()) - else - lowerVideoPaddingPointF.set(0f, 0f) - return lowerVideoPaddingPointF - } - - companion object { - private const val STATE_FORCE_FALLBACK = "frost_state_force_fallback" - const val SELECTED_TAB_ALPHA = 255f - const val UNSELECTED_TAB_ALPHA = 128f - } + companion object { + private const val STATE_FORCE_FALLBACK = "frost_state_force_fallback" + const val SELECTED_TAB_ALPHA = 255f + const val UNSELECTED_TAB_ALPHA = 128f + } } @Module @InstallIn(ActivityComponent::class) object MainActivityModule { - @Provides - @ActivityScoped - fun contract(@ActivityContext context: Context): MainActivityContract = - (context as? BaseMainActivity) - ?: throw IllegalArgumentException("${context::class.java.simpleName} does not implement MainActivityContract") + @Provides + @ActivityScoped + fun contract(@ActivityContext context: Context): MainActivityContract = + (context as? BaseMainActivity) + ?: throw IllegalArgumentException( + "${context::class.java.simpleName} does not implement MainActivityContract" + ) } diff --git a/app/src/main/kotlin/com/pitchedapps/frost/activities/DebugActivity.kt b/app/src/main/kotlin/com/pitchedapps/frost/activities/DebugActivity.kt index 4d2af1238..58dcaa4be 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/activities/DebugActivity.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/activities/DebugActivity.kt @@ -35,110 +35,97 @@ import com.pitchedapps.frost.utils.ActivityThemer import com.pitchedapps.frost.utils.L import com.pitchedapps.frost.utils.createFreshDir import dagger.hilt.android.AndroidEntryPoint -import kotlinx.coroutines.CoroutineExceptionHandler import java.io.File import javax.inject.Inject import kotlin.coroutines.resume import kotlin.coroutines.suspendCoroutine +import kotlinx.coroutines.CoroutineExceptionHandler -/** - * Created by Allan Wang on 05/01/18. - */ +/** Created by Allan Wang on 05/01/18. */ @AndroidEntryPoint class DebugActivity : KauBaseActivity() { - companion object { - const val RESULT_URL = "extra_result_url" - const val RESULT_SCREENSHOT = "extra_result_screenshot" - const val RESULT_BODY = "extra_result_body" - fun baseDir(context: Context) = File(context.externalCacheDir, "offline_debug") + companion object { + const val RESULT_URL = "extra_result_url" + const val RESULT_SCREENSHOT = "extra_result_screenshot" + const val RESULT_BODY = "extra_result_body" + fun baseDir(context: Context) = File(context.externalCacheDir, "offline_debug") + } + + @Inject lateinit var activityThemer: ActivityThemer + + @Inject lateinit var themeProvider: ThemeProvider + + lateinit var binding: ActivityDebugBinding + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + binding = ActivityDebugBinding.inflate(layoutInflater) + setContentView(binding.root) + binding.init() + } + + fun ActivityDebugBinding.init() { + setSupportActionBar(toolbar) + supportActionBar?.apply { + setDisplayHomeAsUpEnabled(true) + setDisplayShowHomeEnabled(true) } + setTitle(R.string.debug_frost) - @Inject - lateinit var activityThemer: ActivityThemer + activityThemer.setFrostColors { toolbar(toolbar) } + debugWebview.loadUrl(FbItem.FEED.url) + debugWebview.onPageFinished = { swipeRefresh.isRefreshing = false } - @Inject - lateinit var themeProvider: ThemeProvider + swipeRefresh.setOnRefreshListener(debugWebview::reload) - lateinit var binding: ActivityDebugBinding + fab.visible().setIcon(GoogleMaterial.Icon.gmd_bug_report, themeProvider.iconColor) + fab.backgroundTintList = ColorStateList.valueOf(themeProvider.accentColor) + fab.setOnClickListener { _ -> + fab.hide() - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - binding = ActivityDebugBinding.inflate(layoutInflater) - setContentView(binding.root) - binding.init() - } - - fun ActivityDebugBinding.init() { - setSupportActionBar(toolbar) - supportActionBar?.apply { - setDisplayHomeAsUpEnabled(true) - setDisplayShowHomeEnabled(true) - } - setTitle(R.string.debug_frost) - - activityThemer.setFrostColors { - toolbar(toolbar) - } - debugWebview.loadUrl(FbItem.FEED.url) - debugWebview.onPageFinished = { swipeRefresh.isRefreshing = false } - - swipeRefresh.setOnRefreshListener(debugWebview::reload) - - fab.visible().setIcon(GoogleMaterial.Icon.gmd_bug_report, themeProvider.iconColor) - fab.backgroundTintList = ColorStateList.valueOf(themeProvider.accentColor) - fab.setOnClickListener { _ -> - fab.hide() - - val errorHandler = CoroutineExceptionHandler { _, throwable -> - L.e { "DebugActivity error ${throwable.message}" } - setResult(Activity.RESULT_CANCELED) - finish() - } - - launchMain(errorHandler) { - val parent = baseDir(this@DebugActivity) - parent.createFreshDir() - - val body: String? = suspendCoroutine { cont -> - debugWebview.evaluateJavascript(JsActions.RETURN_BODY.function) { - cont.resume(it) - } - } - - val hasScreenshot: Boolean = - debugWebview.getScreenshot(File(parent, "screenshot.png")) - - val intent = Intent() - intent.putExtra(RESULT_URL, debugWebview.url) - intent.putExtra(RESULT_SCREENSHOT, hasScreenshot) - if (body != null) - intent.putExtra(RESULT_BODY, body) - setResult(Activity.RESULT_OK, intent) - finish() - } - } - } - - override fun onSupportNavigateUp(): Boolean { + val errorHandler = CoroutineExceptionHandler { _, throwable -> + L.e { "DebugActivity error ${throwable.message}" } + setResult(Activity.RESULT_CANCELED) finish() - return true - } + } - override fun onResume() { - super.onResume() - binding.debugWebview.resumeTimers() - } + launchMain(errorHandler) { + val parent = baseDir(this@DebugActivity) + parent.createFreshDir() - override fun onPause() { - binding.debugWebview.pauseTimers() - super.onPause() - } + val body: String? = suspendCoroutine { cont -> + debugWebview.evaluateJavascript(JsActions.RETURN_BODY.function) { cont.resume(it) } + } - override fun onBackPressed() { - if (binding.debugWebview.canGoBack()) - binding.debugWebview.goBack() - else - super.onBackPressed() + val hasScreenshot: Boolean = debugWebview.getScreenshot(File(parent, "screenshot.png")) + + val intent = Intent() + intent.putExtra(RESULT_URL, debugWebview.url) + intent.putExtra(RESULT_SCREENSHOT, hasScreenshot) + if (body != null) intent.putExtra(RESULT_BODY, body) + setResult(Activity.RESULT_OK, intent) + finish() + } } + } + + override fun onSupportNavigateUp(): Boolean { + finish() + return true + } + + override fun onResume() { + super.onResume() + binding.debugWebview.resumeTimers() + } + + override fun onPause() { + binding.debugWebview.pauseTimers() + super.onPause() + } + + override fun onBackPressed() { + if (binding.debugWebview.canGoBack()) binding.debugWebview.goBack() else super.onBackPressed() + } } diff --git a/app/src/main/kotlin/com/pitchedapps/frost/activities/ImageActivity.kt b/app/src/main/kotlin/com/pitchedapps/frost/activities/ImageActivity.kt index 2700f1b4a..cf23fe141 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/activities/ImageActivity.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/activities/ImageActivity.kt @@ -62,323 +62,309 @@ import com.pitchedapps.frost.utils.frostUriFromFile import com.pitchedapps.frost.utils.isIndirectImageUrl import com.pitchedapps.frost.utils.logFrostEvent import dagger.hilt.android.AndroidEntryPoint -import kotlinx.coroutines.CoroutineExceptionHandler -import kotlinx.coroutines.Deferred -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.async -import kotlinx.coroutines.launch import java.io.File import java.io.FileNotFoundException import javax.inject.Inject import kotlin.math.abs import kotlin.math.max +import kotlinx.coroutines.CoroutineExceptionHandler +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.launch -/** - * Created by Allan Wang on 2017-07-15. - */ +/** Created by Allan Wang on 2017-07-15. */ @AndroidEntryPoint class ImageActivity : KauBaseActivity() { - @Inject - lateinit var activityThemer: ActivityThemer + @Inject lateinit var activityThemer: ActivityThemer - @Inject - lateinit var prefs: Prefs + @Inject lateinit var prefs: Prefs - @Inject - lateinit var themeProvider: ThemeProvider + @Inject lateinit var themeProvider: ThemeProvider - @Volatile - internal var errorRef: Throwable? = null + @Volatile internal var errorRef: Throwable? = null - /** - * Reference to the temporary file path - */ - internal val tempFile: File? get() = binding.imagePhoto.currentImageFile + /** Reference to the temporary file path */ + internal val tempFile: File? + get() = binding.imagePhoto.currentImageFile - private lateinit var dragHelper: ViewDragHelper + private lateinit var dragHelper: ViewDragHelper - private val cookie: String? by lazy { intent.getStringExtra(ARG_COOKIE) } + private val cookie: String? by lazy { intent.getStringExtra(ARG_COOKIE) } - val imageUrl: String by lazy { intent.getStringExtra(ARG_IMAGE_URL)?.trim('"') ?: "" } + val imageUrl: String by lazy { intent.getStringExtra(ARG_IMAGE_URL)?.trim('"') ?: "" } - private lateinit var trueImageUrl: Deferred + private lateinit var trueImageUrl: Deferred - private val imageText: String? by lazy { intent.getStringExtra(ARG_TEXT) } + private val imageText: String? by lazy { intent.getStringExtra(ARG_TEXT) } - lateinit var binding: ActivityImageBinding - private var bottomBehavior: BottomSheetBehavior? = null + lateinit var binding: ActivityImageBinding + private var bottomBehavior: BottomSheetBehavior? = null - private val baseBackgroundColor: Int - get() = if (prefs.blackMediaBg) Color.BLACK - else themeProvider.bgColor.withMinAlpha(235) + private val baseBackgroundColor: Int + get() = if (prefs.blackMediaBg) Color.BLACK else themeProvider.bgColor.withMinAlpha(235) - private fun loadError(e: Throwable) { - if (e.message?.contains("") == true) { - applicationContext.toast(R.string.image_not_found) + private fun loadError(e: Throwable) { + if (e.message?.contains("") == true) { + applicationContext.toast(R.string.image_not_found) + finish() + return + } + errorRef = e + e.logFrostEvent("Image load error") + with(binding) { if (imageProgress.isVisible) imageProgress.fadeOut() } + tempFile?.delete() + binding.error.fadeIn() + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + if (imageUrl.isEmpty()) { + return finish() + } + L.i { "Displaying image" } + trueImageUrl = + async(Dispatchers.IO) { + val result = + if (!imageUrl.isIndirectImageUrl) imageUrl + else cookie?.getFullSizedImageUrl(imageUrl) ?: imageUrl + if (result != imageUrl) L.v { "Launching image with true url $result" } + else L.v { "Launching image with url $result" } + result + } + binding = ActivityImageBinding.inflate(layoutInflater) + setContentView(binding.root) + binding.init() + launch(CoroutineExceptionHandler { _, throwable -> loadError(throwable) }) { + binding.showImage(trueImageUrl.await()) + } + } + + private fun ActivityImageBinding.showImage(url: String) { + imagePhoto.showImage(Uri.parse(url)) + imagePhoto.setImageShownCallback( + object : ImageShownCallback { + override fun onThumbnailShown() {} + + override fun onMainImageShown() { + imageProgress.fadeOut() + imagePhoto.animate().alpha(1f).scaleXY(1f).start() + } + } + ) + } + + private fun ActivityImageBinding.init() { + imageContainer.setBackgroundColor(baseBackgroundColor) + toolbar.setBackgroundColor(baseBackgroundColor) + this@ImageActivity.imageText.also { text -> + if (text.isNullOrBlank()) { + imageText.gone() + } else { + imageText.setTextColor(if (prefs.blackMediaBg) Color.WHITE else themeProvider.textColor) + imageText.setBackgroundColor(baseBackgroundColor.colorToForeground(0.2f).withAlpha(255)) + imageText.text = text + bottomBehavior = + BottomSheetBehavior.from(imageText).apply { + addBottomSheetCallback( + object : BottomSheetBehavior.BottomSheetCallback() { + override fun onSlide(bottomSheet: View, slideOffset: Float) { + imageText.alpha = slideOffset / 2 + 0.5f + } + + override fun onStateChanged(bottomSheet: View, newState: Int) { + // No op + } + } + ) + } + imageText.bringToFront() + } + } + val foregroundTint = if (prefs.blackMediaBg) Color.WHITE else themeProvider.accentColor + + fun ImageView.setState(state: FabStates) { + setIcon(state.iicon, color = foregroundTint, sizeDp = 24) + setOnClickListener { state.onClick(this@ImageActivity) } + } + + imageProgress.tint(foregroundTint) + error.apply { + invisible() + setState(FabStates.ERROR) + } + download.apply { setState(FabStates.DOWNLOAD) } + share.apply { setState(FabStates.SHARE) } + + imagePhoto.setImageLoaderCallback( + object : ImageLoader.Callback { + override fun onCacheHit(imageType: Int, image: File?) {} + + override fun onCacheMiss(imageType: Int, image: File?) {} + + override fun onStart() {} + + override fun onProgress(progress: Int) {} + + override fun onFinish() {} + + override fun onSuccess(image: File) {} + + override fun onFail(error: Exception) { + loadError(error) + } + } + ) + + activityThemer.setFrostColors { themeWindow = false } + dragHelper = + ViewDragHelper.create(imageDrag, ViewDragCallback()).apply { + setEdgeTrackingEnabled(ViewDragHelper.EDGE_TOP or ViewDragHelper.EDGE_BOTTOM) + } + imageDrag.dragHelper = dragHelper + imageDrag.viewToIgnore = imageText + } + + private inner class ViewDragCallback : ViewDragHelper.Callback() { + private var scrollPercent: Float = 0f + private var scrollThreshold = 0.5f + private var scrollToTop = false + + override fun tryCaptureView(view: View, i: Int): Boolean { + L.d { "Try capture ${view.id} $i ${binding.imagePhoto.id} ${binding.imageText.id}" } + return view === binding.imagePhoto + } + + override fun getViewHorizontalDragRange(child: View): Int = 0 + + override fun getViewVerticalDragRange(child: View): Int = child.height + + override fun onViewPositionChanged(changedView: View, left: Int, top: Int, dx: Int, dy: Int) { + super.onViewPositionChanged(changedView, left, top, dx, dy) + with(binding) { + // make sure that we are using the proper axis + scrollPercent = abs(top.toFloat() / imageContainer.height) + scrollToTop = top < 0 + val multiplier = max(1f - scrollPercent, 0f) + + toolbar.alpha = multiplier + bottomBehavior?.also { + imageText.alpha = + multiplier * (if (it.state == BottomSheetBehavior.STATE_COLLAPSED) 0.5f else 1f) + } + imageContainer.setBackgroundColor(baseBackgroundColor.adjustAlpha(multiplier)) + + if (scrollPercent >= 1) { + if (!isFinishing) { finish() - return + overridePendingTransition(0, 0) + } } - errorRef = e - e.logFrostEvent("Image load error") - with(binding) { - if (imageProgress.isVisible) - imageProgress.fadeOut() - } - tempFile?.delete() - binding.error.fadeIn() + } } - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - if (imageUrl.isEmpty()) { - return finish() - } - L.i { "Displaying image" } - trueImageUrl = async(Dispatchers.IO) { - val result = if (!imageUrl.isIndirectImageUrl) imageUrl - else cookie?.getFullSizedImageUrl(imageUrl) ?: imageUrl - if (result != imageUrl) - L.v { "Launching image with true url $result" } - else - L.v { "Launching image with url $result" } - result - } - binding = ActivityImageBinding.inflate(layoutInflater) - setContentView(binding.root) - binding.init() - launch(CoroutineExceptionHandler { _, throwable -> loadError(throwable) }) { - binding.showImage(trueImageUrl.await()) + override fun onViewReleased(releasedChild: View, xvel: Float, yvel: Float) { + val overScrolled = scrollPercent > scrollThreshold + val maxOffset = releasedChild.height + 10 + val finalTop = + when { + scrollToTop && (overScrolled || yvel < -dragHelper.minVelocity) -> -maxOffset + !scrollToTop && (overScrolled || yvel > dragHelper.minVelocity) -> maxOffset + else -> 0 } + dragHelper.settleCapturedViewAt(0, finalTop) + binding.imageDrag.invalidate() } - private fun ActivityImageBinding.showImage(url: String) { - imagePhoto.showImage(Uri.parse(url)) - imagePhoto.setImageShownCallback(object : ImageShownCallback { - override fun onThumbnailShown() {} + override fun clampViewPositionHorizontal(child: View, left: Int, dx: Int): Int = 0 - override fun onMainImageShown() { - imageProgress.fadeOut() - imagePhoto.animate().alpha(1f).scaleXY(1f).start() - } - }) - } + override fun clampViewPositionVertical(child: View, top: Int, dy: Int): Int = top + } - private fun ActivityImageBinding.init() { - imageContainer.setBackgroundColor(baseBackgroundColor) - toolbar.setBackgroundColor(baseBackgroundColor) - this@ImageActivity.imageText.also { text -> - if (text.isNullOrBlank()) { - imageText.gone() - } else { - imageText.setTextColor(if (prefs.blackMediaBg) Color.WHITE else themeProvider.textColor) - imageText.setBackgroundColor( - baseBackgroundColor.colorToForeground(0.2f).withAlpha(255) - ) - imageText.text = text - bottomBehavior = BottomSheetBehavior.from(imageText).apply { - addBottomSheetCallback(object : BottomSheetBehavior.BottomSheetCallback() { - override fun onSlide(bottomSheet: View, slideOffset: Float) { - imageText.alpha = slideOffset / 2 + 0.5f - } + internal suspend fun saveImage() { + frostDownload(cookie = cookie, url = trueImageUrl.await()) + } - override fun onStateChanged(bottomSheet: View, newState: Int) { - // No op - } - }) - } - imageText.bringToFront() - } - } - val foregroundTint = if (prefs.blackMediaBg) Color.WHITE else themeProvider.accentColor - - fun ImageView.setState(state: FabStates) { - setIcon(state.iicon, color = foregroundTint, sizeDp = 24) - setOnClickListener { state.onClick(this@ImageActivity) } - } - - imageProgress.tint(foregroundTint) - error.apply { - invisible() - setState(FabStates.ERROR) - } - download.apply { - setState(FabStates.DOWNLOAD) - } - share.apply { - setState(FabStates.SHARE) - } - - imagePhoto.setImageLoaderCallback(object : ImageLoader.Callback { - override fun onCacheHit(imageType: Int, image: File?) {} - - override fun onCacheMiss(imageType: Int, image: File?) {} - - override fun onStart() {} - - override fun onProgress(progress: Int) {} - - override fun onFinish() {} - - override fun onSuccess(image: File) {} - - override fun onFail(error: Exception) { - loadError(error) - } - }) - - activityThemer.setFrostColors { - themeWindow = false - } - dragHelper = ViewDragHelper.create(imageDrag, ViewDragCallback()).apply { - setEdgeTrackingEnabled(ViewDragHelper.EDGE_TOP or ViewDragHelper.EDGE_BOTTOM) - } - imageDrag.dragHelper = dragHelper - imageDrag.viewToIgnore = imageText - } - - private inner class ViewDragCallback : ViewDragHelper.Callback() { - private var scrollPercent: Float = 0f - private var scrollThreshold = 0.5f - private var scrollToTop = false - - override fun tryCaptureView(view: View, i: Int): Boolean { - L.d { "Try capture ${view.id} $i ${binding.imagePhoto.id} ${binding.imageText.id}" } - return view === binding.imagePhoto - } - - override fun getViewHorizontalDragRange(child: View): Int = 0 - - override fun getViewVerticalDragRange(child: View): Int = child.height - - override fun onViewPositionChanged( - changedView: View, - left: Int, - top: Int, - dx: Int, - dy: Int - ) { - super.onViewPositionChanged(changedView, left, top, dx, dy) - with(binding) { - // make sure that we are using the proper axis - scrollPercent = abs(top.toFloat() / imageContainer.height) - scrollToTop = top < 0 - val multiplier = max(1f - scrollPercent, 0f) - - toolbar.alpha = multiplier - bottomBehavior?.also { - imageText.alpha = - multiplier * (if (it.state == BottomSheetBehavior.STATE_COLLAPSED) 0.5f else 1f) - } - imageContainer.setBackgroundColor(baseBackgroundColor.adjustAlpha(multiplier)) - - if (scrollPercent >= 1) { - if (!isFinishing) { - finish() - overridePendingTransition(0, 0) - } - } - } - } - - override fun onViewReleased(releasedChild: View, xvel: Float, yvel: Float) { - val overScrolled = scrollPercent > scrollThreshold - val maxOffset = releasedChild.height + 10 - val finalTop = when { - scrollToTop && (overScrolled || yvel < -dragHelper.minVelocity) -> -maxOffset - !scrollToTop && (overScrolled || yvel > dragHelper.minVelocity) -> maxOffset - else -> 0 - } - dragHelper.settleCapturedViewAt(0, finalTop) - binding.imageDrag.invalidate() - } - - override fun clampViewPositionHorizontal(child: View, left: Int, dx: Int): Int = 0 - - override fun clampViewPositionVertical(child: View, top: Int, dy: Int): Int = top - } - - internal suspend fun saveImage() { - frostDownload(cookie = cookie, url = trueImageUrl.await()) - } - - companion object { - private val L = KauLoggerExtension("Image", com.pitchedapps.frost.utils.L) - } + companion object { + private val L = KauLoggerExtension("Image", com.pitchedapps.frost.utils.L) + } } internal enum class FabStates( - val iicon: IIcon, - val iconColorProvider: (ThemeProvider) -> Int = { it.iconColor }, - val backgroundTint: Int = Int.MAX_VALUE + val iicon: IIcon, + val iconColorProvider: (ThemeProvider) -> Int = { it.iconColor }, + val backgroundTint: Int = Int.MAX_VALUE ) { - ERROR(GoogleMaterial.Icon.gmd_error, { Color.WHITE }, Color.RED) { - override fun onClick(activity: ImageActivity) { - val err = - activity.errorRef?.takeIf { it !is FileNotFoundException && it.message != "Image failed to decode using JPEG decoder" } - ?: return - activity.materialDialog { - title(R.string.kau_error) - message(text = err.message ?: err.javaClass.name) - } - } - }, - NOTHING(GoogleMaterial.Icon.gmd_adjust) { - override fun onClick(activity: ImageActivity) {} - }, - DOWNLOAD(GoogleMaterial.Icon.gmd_file_download) { - override fun onClick(activity: ImageActivity) { - activity.launch { - activity.binding.download.fadeOut() - activity.saveImage() - } - } - }, - SHARE(GoogleMaterial.Icon.gmd_share) { - override fun onClick(activity: ImageActivity) { - val file = activity.tempFile ?: return - try { - val photoURI = activity.frostUriFromFile(file) - val intent = Intent(Intent.ACTION_SEND).apply { - flags = Intent.FLAG_ACTIVITY_NEW_TASK - putExtra(Intent.EXTRA_STREAM, photoURI) - type = "image/png" - } - activity.startActivity(intent) - } catch (e: Exception) { - activity.errorRef = e - e.logFrostEvent("Image share failed") - activity.frostSnackbar(R.string.image_share_failed, activity.themeProvider) - } - } - }; - - /** - * Change the fab look - * If it's in view, give it some animations - * - * TODO investigate what is wrong with fadeScaleTransition - * - * https://github.com/AllanWang/KAU/issues/184 - * - */ - fun update(fab: FloatingActionButton, themeProvider: ThemeProvider) { - val tint = - if (backgroundTint != Int.MAX_VALUE) backgroundTint else themeProvider.accentColor - val iconColor = iconColorProvider(themeProvider) - if (fab.isHidden) { - fab.setIcon(iicon, color = iconColor) - fab.backgroundTintList = ColorStateList.valueOf(tint) - fab.show() - } else { - fab.hide(object : FloatingActionButton.OnVisibilityChangedListener() { - override fun onHidden(fab: FloatingActionButton) { - fab.setIcon(iicon, color = iconColor) - fab.show() - } - }) + ERROR(GoogleMaterial.Icon.gmd_error, { Color.WHITE }, Color.RED) { + override fun onClick(activity: ImageActivity) { + val err = + activity.errorRef?.takeIf { + it !is FileNotFoundException && it.message != "Image failed to decode using JPEG decoder" } + ?: return + activity.materialDialog { + title(R.string.kau_error) + message(text = err.message ?: err.javaClass.name) + } } + }, + NOTHING(GoogleMaterial.Icon.gmd_adjust) { + override fun onClick(activity: ImageActivity) {} + }, + DOWNLOAD(GoogleMaterial.Icon.gmd_file_download) { + override fun onClick(activity: ImageActivity) { + activity.launch { + activity.binding.download.fadeOut() + activity.saveImage() + } + } + }, + SHARE(GoogleMaterial.Icon.gmd_share) { + override fun onClick(activity: ImageActivity) { + val file = activity.tempFile ?: return + try { + val photoURI = activity.frostUriFromFile(file) + val intent = + Intent(Intent.ACTION_SEND).apply { + flags = Intent.FLAG_ACTIVITY_NEW_TASK + putExtra(Intent.EXTRA_STREAM, photoURI) + type = "image/png" + } + activity.startActivity(intent) + } catch (e: Exception) { + activity.errorRef = e + e.logFrostEvent("Image share failed") + activity.frostSnackbar(R.string.image_share_failed, activity.themeProvider) + } + } + }; - abstract fun onClick(activity: ImageActivity) + /** + * Change the fab look If it's in view, give it some animations + * + * TODO investigate what is wrong with fadeScaleTransition + * + * https://github.com/AllanWang/KAU/issues/184 + */ + fun update(fab: FloatingActionButton, themeProvider: ThemeProvider) { + val tint = if (backgroundTint != Int.MAX_VALUE) backgroundTint else themeProvider.accentColor + val iconColor = iconColorProvider(themeProvider) + if (fab.isHidden) { + fab.setIcon(iicon, color = iconColor) + fab.backgroundTintList = ColorStateList.valueOf(tint) + fab.show() + } else { + fab.hide( + object : FloatingActionButton.OnVisibilityChangedListener() { + override fun onHidden(fab: FloatingActionButton) { + fab.setIcon(iicon, color = iconColor) + fab.show() + } + } + ) + } + } + + abstract fun onClick(activity: ImageActivity) } diff --git a/app/src/main/kotlin/com/pitchedapps/frost/activities/IntroActivity.kt b/app/src/main/kotlin/com/pitchedapps/frost/activities/IntroActivity.kt index 02b7fb9dd..6af44463b 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/activities/IntroActivity.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/activities/IntroActivity.kt @@ -55,190 +55,174 @@ import com.pitchedapps.frost.utils.launchNewTask import com.pitchedapps.frost.utils.loadAssets import com.pitchedapps.frost.widgets.NotificationWidget import dagger.hilt.android.AndroidEntryPoint +import javax.inject.Inject import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.launch -import javax.inject.Inject /** * Created by Allan Wang on 2017-07-25. * - * A beautiful intro activity - * Phone showcases are drawn via layers + * A beautiful intro activity Phone showcases are drawn via layers */ @AndroidEntryPoint -class IntroActivity : - KauBaseActivity(), - ViewPager.PageTransformer, - ViewPager.OnPageChangeListener { +class IntroActivity : KauBaseActivity(), ViewPager.PageTransformer, ViewPager.OnPageChangeListener { - @Inject - lateinit var prefs: Prefs + @Inject lateinit var prefs: Prefs - @Inject - lateinit var themeProvider: ThemeProvider + @Inject lateinit var themeProvider: ThemeProvider - @Inject - lateinit var activityThemer: ActivityThemer + @Inject lateinit var activityThemer: ActivityThemer - lateinit var binding: ActivityIntroBinding - private var barHasNext = true + lateinit var binding: ActivityIntroBinding + private var barHasNext = true - private val fragments by lazyUi { - listOf( - IntroFragmentWelcome(), - IntroFragmentTheme(), - IntroAccountFragment(), - IntroTabTouchFragment(), - IntroTabContextFragment(), - IntroFragmentEnd() - ) + private val fragments by lazyUi { + listOf( + IntroFragmentWelcome(), + IntroFragmentTheme(), + IntroAccountFragment(), + IntroTabTouchFragment(), + IntroTabContextFragment(), + IntroFragmentEnd() + ) + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + binding = ActivityIntroBinding.inflate(layoutInflater) + setContentView(binding.root) + binding.init() + } + + private fun ActivityIntroBinding.init() { + viewpager.apply { + setPageTransformer(true, this@IntroActivity) + addOnPageChangeListener(this@IntroActivity) + adapter = IntroPageAdapter(supportFragmentManager, fragments) } - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - binding = ActivityIntroBinding.inflate(layoutInflater) - setContentView(binding.root) - binding.init() + indicator.setViewPager(viewpager) + next.setIcon(GoogleMaterial.Icon.gmd_navigate_next) + next.setOnClickListener { + if (barHasNext) viewpager.setCurrentItem(viewpager.currentItem + 1, true) + else finish(next.x + next.pivotX, next.y + next.pivotY) } + skip.setOnClickListener { finish() } + ripple.set(themeProvider.bgColor) + theme() + } - private fun ActivityIntroBinding.init() { - viewpager.apply { - setPageTransformer(true, this@IntroActivity) - addOnPageChangeListener(this@IntroActivity) - adapter = IntroPageAdapter(supportFragmentManager, fragments) - } - indicator.setViewPager(viewpager) - next.setIcon(GoogleMaterial.Icon.gmd_navigate_next) - next.setOnClickListener { - if (barHasNext) viewpager.setCurrentItem(viewpager.currentItem + 1, true) - else finish(next.x + next.pivotX, next.y + next.pivotY) - } - skip.setOnClickListener { finish() } - ripple.set(themeProvider.bgColor) - theme() + fun theme() { + statusBarColor = themeProvider.headerColor + navigationBarColor = themeProvider.headerColor + with(binding) { + skip.setTextColor(themeProvider.textColor) + next.imageTintList = ColorStateList.valueOf(themeProvider.textColor) + indicator.setColour(themeProvider.textColor) + indicator.invalidate() } + fragments.forEach { it.themeFragment() } + activityThemer.setFrostTheme(forceTransparent = true) + } - fun theme() { - statusBarColor = themeProvider.headerColor - navigationBarColor = themeProvider.headerColor - with(binding) { - skip.setTextColor(themeProvider.textColor) - next.imageTintList = ColorStateList.valueOf(themeProvider.textColor) - indicator.setColour(themeProvider.textColor) - indicator.invalidate() - } - fragments.forEach { it.themeFragment() } - activityThemer.setFrostTheme(forceTransparent = true) + /** + * Transformations are mainly handled on a per view basis This makes the first fragment fade out + * as the second fragment comes in All fragments are locked in position + */ + override fun transformPage(page: View, position: Float) { + // only apply to adjacent pages + if ((position < 0 && position > -1) || (position > 0 && position < 1)) { + val pageWidth = page.width + val translateValue = position * -pageWidth + page.translationX = (if (translateValue > -pageWidth) translateValue else 0f) + page.alpha = if (position < 0) 1 + position else 1f + } else { + page.alpha = 1f + page.translationX = 0f } + } - /** - * Transformations are mainly handled on a per view basis - * This makes the first fragment fade out as the second fragment comes in - * All fragments are locked in position - */ - override fun transformPage(page: View, position: Float) { - // only apply to adjacent pages - if ((position < 0 && position > -1) || (position > 0 && position < 1)) { - val pageWidth = page.width - val translateValue = position * -pageWidth - page.translationX = (if (translateValue > -pageWidth) translateValue else 0f) - page.alpha = if (position < 0) 1 + position else 1f - } else { - page.alpha = 1f - page.translationX = 0f + fun finish(x: Float, y: Float) { + val blue = color(R.color.facebook_blue) + window.setFlags( + WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE, + WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE + ) + binding.ripple.ripple(blue, x, y, 600) { postDelayed(1000) { finish() } } + val lastView: View? = fragments.last().view + arrayOf( + binding.skip, + binding.indicator, + binding.next, + lastView?.findViewById(R.id.intro_title), + lastView?.findViewById(R.id.intro_desc) + ) + .forEach { it?.animate()?.alpha(0f)?.setDuration(600)?.start() } + if (themeProvider.textColor != Color.WHITE) { + val f = lastView?.findViewById(R.id.intro_image)?.drawable + if (f != null) + ValueAnimator.ofFloat(0f, 1f).apply { + addUpdateListener { + f.setTint(themeProvider.textColor.blendWith(Color.WHITE, it.animatedValue as Float)) + } + duration = 600 + start() } } - - fun finish(x: Float, y: Float) { - val blue = color(R.color.facebook_blue) - window.setFlags( - WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE, - WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE - ) - binding.ripple.ripple(blue, x, y, 600) { - postDelayed(1000) { finish() } - } - val lastView: View? = fragments.last().view - arrayOf( - binding.skip, binding.indicator, binding.next, - lastView?.findViewById(R.id.intro_title), - lastView?.findViewById(R.id.intro_desc) - ).forEach { - it?.animate()?.alpha(0f)?.setDuration(600)?.start() - } - if (themeProvider.textColor != Color.WHITE) { - val f = lastView?.findViewById(R.id.intro_image)?.drawable - if (f != null) - ValueAnimator.ofFloat(0f, 1f).apply { - addUpdateListener { - f.setTint( - themeProvider.textColor.blendWith( - Color.WHITE, - it.animatedValue as Float - ) - ) - } - duration = 600 - start() - } - } - if (themeProvider.headerColor != blue) { - ValueAnimator.ofFloat(0f, 1f).apply { - addUpdateListener { - val c = themeProvider.headerColor.blendWith(blue, it.animatedValue as Float) - statusBarColor = c - navigationBarColor = c - } - duration = 600 - start() - } + if (themeProvider.headerColor != blue) { + ValueAnimator.ofFloat(0f, 1f).apply { + addUpdateListener { + val c = themeProvider.headerColor.blendWith(blue, it.animatedValue as Float) + statusBarColor = c + navigationBarColor = c } + duration = 600 + start() + } } + } - override fun finish() { - launch(NonCancellable) { - loadAssets(themeProvider) - NotificationWidget.forceUpdate(this@IntroActivity) - launchNewTask(cookies(), false) - super.finish() - } + override fun finish() { + launch(NonCancellable) { + loadAssets(themeProvider) + NotificationWidget.forceUpdate(this@IntroActivity) + launchNewTask(cookies(), false) + super.finish() } + } - override fun onBackPressed() { - with(binding) { - if (viewpager.currentItem > 0) viewpager.setCurrentItem(viewpager.currentItem - 1, true) - else finish() - } + override fun onBackPressed() { + with(binding) { + if (viewpager.currentItem > 0) viewpager.setCurrentItem(viewpager.currentItem - 1, true) + else finish() } + } - override fun onPageScrollStateChanged(state: Int) { + override fun onPageScrollStateChanged(state: Int) {} + + override fun onPageScrolled(position: Int, positionOffset: Float, positionOffsetPixels: Int) { + fragments[position].onPageScrolled(positionOffset) + if (position + 1 < fragments.size) fragments[position + 1].onPageScrolled(positionOffset - 1) + } + + override fun onPageSelected(position: Int) { + fragments[position].onPageSelected() + val hasNext = position != fragments.size - 1 + if (barHasNext == hasNext) return + barHasNext = hasNext + binding.next.fadeScaleTransition { + setIcon( + if (barHasNext) GoogleMaterial.Icon.gmd_navigate_next else GoogleMaterial.Icon.gmd_done, + color = themeProvider.textColor + ) } + binding.skip.animate().scaleXY(if (barHasNext) 1f else 0f) + } - override fun onPageScrolled(position: Int, positionOffset: Float, positionOffsetPixels: Int) { - fragments[position].onPageScrolled(positionOffset) - if (position + 1 < fragments.size) - fragments[position + 1].onPageScrolled(positionOffset - 1) - } + class IntroPageAdapter(fm: FragmentManager, private val fragments: List) : + FragmentPagerAdapter(fm) { - override fun onPageSelected(position: Int) { - fragments[position].onPageSelected() - val hasNext = position != fragments.size - 1 - if (barHasNext == hasNext) return - barHasNext = hasNext - binding.next.fadeScaleTransition { - setIcon( - if (barHasNext) GoogleMaterial.Icon.gmd_navigate_next else GoogleMaterial.Icon.gmd_done, - color = themeProvider.textColor - ) - } - binding.skip.animate().scaleXY(if (barHasNext) 1f else 0f) - } + override fun getItem(position: Int): Fragment = fragments[position] - class IntroPageAdapter(fm: FragmentManager, private val fragments: List) : - FragmentPagerAdapter(fm) { - - override fun getItem(position: Int): Fragment = fragments[position] - - override fun getCount(): Int = fragments.size - } + override fun getCount(): Int = fragments.size + } } diff --git a/app/src/main/kotlin/com/pitchedapps/frost/activities/LoginActivity.kt b/app/src/main/kotlin/com/pitchedapps/frost/activities/LoginActivity.kt index a95e931bd..97e94cbad 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/activities/LoginActivity.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/activities/LoginActivity.kt @@ -49,6 +49,9 @@ import com.pitchedapps.frost.web.FrostEmitter import com.pitchedapps.frost.web.LoginWebView import com.pitchedapps.frost.web.asFrostEmitter import dagger.hilt.android.AndroidEntryPoint +import java.net.UnknownHostException +import javax.inject.Inject +import kotlin.coroutines.resume import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async import kotlinx.coroutines.channels.BufferOverflow @@ -63,165 +66,157 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeout -import java.net.UnknownHostException -import javax.inject.Inject -import kotlin.coroutines.resume -/** - * Created by Allan Wang on 2017-06-01. - */ +/** Created by Allan Wang on 2017-06-01. */ @AndroidEntryPoint class LoginActivity : BaseActivity() { - @Inject - lateinit var cookieDao: CookieDao + @Inject lateinit var cookieDao: CookieDao - private val toolbar: Toolbar by bindView(R.id.toolbar) - private val web: LoginWebView by bindView(R.id.login_webview) - private val swipeRefresh: SwipeRefreshLayout by bindView(R.id.swipe_refresh) - private val textview: AppCompatTextView by bindView(R.id.textview) - private val profile: ImageView by bindView(R.id.profile) + private val toolbar: Toolbar by bindView(R.id.toolbar) + private val web: LoginWebView by bindView(R.id.login_webview) + private val swipeRefresh: SwipeRefreshLayout by bindView(R.id.swipe_refresh) + private val textview: AppCompatTextView by bindView(R.id.textview) + private val profile: ImageView by bindView(R.id.profile) - private lateinit var profileLoader: RequestManager + private lateinit var profileLoader: RequestManager - private val refreshMutableFlow = MutableSharedFlow( - extraBufferCapacity = 10, - onBufferOverflow = BufferOverflow.DROP_OLDEST + private val refreshMutableFlow = + MutableSharedFlow( + extraBufferCapacity = 10, + onBufferOverflow = BufferOverflow.DROP_OLDEST ) - private val refreshFlow: SharedFlow = refreshMutableFlow.asSharedFlow() + private val refreshFlow: SharedFlow = refreshMutableFlow.asSharedFlow() - private val refreshEmit: FrostEmitter = refreshMutableFlow.asFrostEmitter() + private val refreshEmit: FrostEmitter = refreshMutableFlow.asFrostEmitter() - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - setContentView(R.layout.activity_login) - setSupportActionBar(toolbar) - setTitle(R.string.kau_login) - activityThemer.setFrostColors { - toolbar(toolbar) - } - profileLoader = GlideApp.with(profile) + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(R.layout.activity_login) + setSupportActionBar(toolbar) + setTitle(R.string.kau_login) + activityThemer.setFrostColors { toolbar(toolbar) } + profileLoader = GlideApp.with(profile) - refreshFlow - .distinctUntilChanged() - .onEach { swipeRefresh.isRefreshing = it } - .launchIn(this) + refreshFlow.distinctUntilChanged().onEach { swipeRefresh.isRefreshing = it }.launchIn(this) - launch { - val cookie = web.loadLogin { refresh(it != 100) }.await() - L.d { "Login found" } - fbCookie.save(cookie.id) - webFadeOut() - profile.fadeIn() - loadInfo(cookie) - } + launch { + val cookie = web.loadLogin { refresh(it != 100) }.await() + L.d { "Login found" } + fbCookie.save(cookie.id) + webFadeOut() + profile.fadeIn() + loadInfo(cookie) + } + } + + private suspend fun webFadeOut(): Unit = suspendCancellableCoroutine { cont -> + web.fadeOut { cont.resume(Unit) } + } + + private fun refresh(refreshing: Boolean) { + refreshEmit(refreshing) + } + + private suspend fun loadInfo(cookie: CookieEntity): Unit = withMainContext { + refresh(true) + + val imageDeferred = async { loadProfile(cookie.id) } + val nameDeferred = async { loadUsername(cookie) } + + val name: String? = nameDeferred.await() + val foundImage: Boolean = imageDeferred.await() + + L._d { "Logged in and received data" } + refresh(false) + + if (!foundImage) { + L.e { "Could not get profile photo; Invalid userId?" } + L._i { cookie } } - private suspend fun webFadeOut(): Unit = suspendCancellableCoroutine { cont -> - web.fadeOut { cont.resume(Unit) } - } + textview.text = String.format(getString(R.string.welcome), name ?: "") + textview.fadeIn() + frostEvent("Login", "success" to true) - private fun refresh(refreshing: Boolean) { - refreshEmit(refreshing) - } + /* + * The user may have logged into an account that is already in the database + * We will let the db handle duplicates and load it now after the new account has been saved + */ + val cookies = ArrayList(cookieDao.selectAll()) + delay(1000) + if (prefs.intro) launchNewTask(cookies, true) + else launchNewTask(cookies, true) + } - private suspend fun loadInfo(cookie: CookieEntity): Unit = withMainContext { - refresh(true) - - val imageDeferred = async { loadProfile(cookie.id) } - val nameDeferred = async { loadUsername(cookie) } - - val name: String? = nameDeferred.await() - val foundImage: Boolean = imageDeferred.await() - - L._d { "Logged in and received data" } - refresh(false) - - if (!foundImage) { - L.e { "Could not get profile photo; Invalid userId?" } - L._i { cookie } - } - - textview.text = String.format(getString(R.string.welcome), name ?: "") - textview.fadeIn() - frostEvent("Login", "success" to true) - - /* - * The user may have logged into an account that is already in the database - * We will let the db handle duplicates and load it now after the new account has been saved - */ - val cookies = ArrayList(cookieDao.selectAll()) - delay(1000) - if (prefs.intro) - launchNewTask(cookies, true) - else - launchNewTask(cookies, true) - } - - private suspend fun loadProfile(id: Long): Boolean = withMainContext { - suspendCancellableCoroutine { cont -> - profileLoader.load(profilePictureUrl(id)) - .transform(FrostGlide.circleCrop).listener(object : RequestListener { - override fun onResourceReady( - resource: Drawable?, - model: Any?, - target: Target?, - dataSource: DataSource?, - isFirstResource: Boolean - ): Boolean { - cont.resume(true) - return false - } - - override fun onLoadFailed( - e: GlideException?, - model: Any?, - target: Target?, - isFirstResource: Boolean - ): Boolean { - e.logFrostEvent("Profile loading exception") - cont.resume(false) - return false - } - }).into(profile) - } - } - - private suspend fun loadUsername(cookie: CookieEntity): String? = withContext(Dispatchers.IO) { - val result: String? = try { - withTimeout(5000) { - frostJsoup(cookie.cookie, FbItem.PROFILE.url).title() + private suspend fun loadProfile(id: Long): Boolean = withMainContext { + suspendCancellableCoroutine { cont -> + profileLoader + .load(profilePictureUrl(id)) + .transform(FrostGlide.circleCrop) + .listener( + object : RequestListener { + override fun onResourceReady( + resource: Drawable?, + model: Any?, + target: Target?, + dataSource: DataSource?, + isFirstResource: Boolean + ): Boolean { + cont.resume(true) + return false } + + override fun onLoadFailed( + e: GlideException?, + model: Any?, + target: Target?, + isFirstResource: Boolean + ): Boolean { + e.logFrostEvent("Profile loading exception") + cont.resume(false) + return false + } + } + ) + .into(profile) + } + } + + private suspend fun loadUsername(cookie: CookieEntity): String? = + withContext(Dispatchers.IO) { + val result: String? = + try { + withTimeout(5000) { frostJsoup(cookie.cookie, FbItem.PROFILE.url).title() } } catch (e: Exception) { - if (e !is UnknownHostException) - e.logFrostEvent("Fetch username failed") - null + if (e !is UnknownHostException) e.logFrostEvent("Fetch username failed") + null } - if (result != null) { - cookieDao.save(cookie.copy(name = result)) - return@withContext result - } + if (result != null) { + cookieDao.save(cookie.copy(name = result)) + return@withContext result + } - return@withContext cookie.name + return@withContext cookie.name } - override fun backConsumer(): Boolean { - if (web.canGoBack()) { - web.goBack() - return true - } - return false + override fun backConsumer(): Boolean { + if (web.canGoBack()) { + web.goBack() + return true } + return false + } - override fun onResume() { - super.onResume() - web.resumeTimers() - } + override fun onResume() { + super.onResume() + web.resumeTimers() + } - override fun onPause() { - web.pauseTimers() - super.onPause() - } + override fun onPause() { + web.pauseTimers() + super.onPause() + } } diff --git a/app/src/main/kotlin/com/pitchedapps/frost/activities/MainActivity.kt b/app/src/main/kotlin/com/pitchedapps/frost/activities/MainActivity.kt index 166066912..dc31aa5d2 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/activities/MainActivity.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/activities/MainActivity.kt @@ -40,93 +40,92 @@ import kotlinx.coroutines.flow.onEach class MainActivity : BaseMainActivity() { - private val fragmentMutableFlow = MutableSharedFlow( - extraBufferCapacity = 10, - onBufferOverflow = BufferOverflow.DROP_OLDEST - ) - override val fragmentFlow: SharedFlow = fragmentMutableFlow.asSharedFlow() - override val fragmentEmit: FrostEmitter = fragmentMutableFlow.asFrostEmitter() + private val fragmentMutableFlow = + MutableSharedFlow(extraBufferCapacity = 10, onBufferOverflow = BufferOverflow.DROP_OLDEST) + override val fragmentFlow: SharedFlow = fragmentMutableFlow.asSharedFlow() + override val fragmentEmit: FrostEmitter = fragmentMutableFlow.asFrostEmitter() - private val headerMutableFlow = MutableStateFlow("") - override val headerFlow: SharedFlow = headerMutableFlow.asSharedFlow() - override val headerEmit: FrostEmitter = headerMutableFlow.asFrostEmitter() + private val headerMutableFlow = MutableStateFlow("") + override val headerFlow: SharedFlow = headerMutableFlow.asSharedFlow() + override val headerEmit: FrostEmitter = headerMutableFlow.asFrostEmitter() - override fun onNestedCreate(savedInstanceState: Bundle?) { - with(contentBinding) { - setupTabs() - setupViewPager() + override fun onNestedCreate(savedInstanceState: Bundle?) { + with(contentBinding) { + setupTabs() + setupViewPager() + } + } + + private fun ActivityMainContentBinding.setupViewPager() { + viewpager.addOnPageChangeListener( + object : ViewPager.SimpleOnPageChangeListener() { + override fun onPageSelected(position: Int) { + super.onPageSelected(position) + if (lastPosition == position) { + return + } + if (lastPosition != -1) { + fragmentEmit(-(lastPosition + 1)) + } + fragmentEmit(position) + lastPosition = position } - } - private fun ActivityMainContentBinding.setupViewPager() { - viewpager.addOnPageChangeListener(object : ViewPager.SimpleOnPageChangeListener() { - override fun onPageSelected(position: Int) { - super.onPageSelected(position) - if (lastPosition == position) { - return - } - if (lastPosition != -1) { - fragmentEmit(-(lastPosition + 1)) - } - fragmentEmit(position) - lastPosition = position - } + override fun onPageScrolled( + position: Int, + positionOffset: Float, + positionOffsetPixels: Int + ) { + super.onPageScrolled(position, positionOffset, positionOffsetPixels) + val delta = positionOffset * (SELECTED_TAB_ALPHA - UNSELECTED_TAB_ALPHA) + tabsForEachView { tabPosition, view -> + view.setAllAlpha( + when (tabPosition) { + position -> SELECTED_TAB_ALPHA - delta + position + 1 -> UNSELECTED_TAB_ALPHA + delta + else -> UNSELECTED_TAB_ALPHA + } + ) + } + } + } + ) + } - override fun onPageScrolled( - position: Int, - positionOffset: Float, - positionOffsetPixels: Int - ) { - super.onPageScrolled(position, positionOffset, positionOffsetPixels) - val delta = positionOffset * (SELECTED_TAB_ALPHA - UNSELECTED_TAB_ALPHA) - tabsForEachView { tabPosition, view -> - view.setAllAlpha( - when (tabPosition) { - position -> SELECTED_TAB_ALPHA - delta - position + 1 -> UNSELECTED_TAB_ALPHA + delta - else -> UNSELECTED_TAB_ALPHA - } - ) - } - } - }) - } + private fun ActivityMainContentBinding.setupTabs() { + viewpager.addOnPageChangeListener(TabLayout.TabLayoutOnPageChangeListener(tabs)) + tabs.addOnTabSelectedListener( + object : TabLayout.ViewPagerOnTabSelectedListener(viewpager) { + override fun onTabReselected(tab: TabLayout.Tab) { + super.onTabReselected(tab) + currentFragment?.onTabClick() + } - private fun ActivityMainContentBinding.setupTabs() { - viewpager.addOnPageChangeListener(TabLayout.TabLayoutOnPageChangeListener(tabs)) - tabs.addOnTabSelectedListener(object : TabLayout.ViewPagerOnTabSelectedListener(viewpager) { - override fun onTabReselected(tab: TabLayout.Tab) { - super.onTabReselected(tab) - currentFragment?.onTabClick() - } - - override fun onTabSelected(tab: TabLayout.Tab) { - super.onTabSelected(tab) - (tab.customView as BadgedIcon).badgeText = null - } - }) - headerFlow - .filter { it.isNotBlank() } - .mapNotNull { html -> - BadgeParser.parseFromData( - cookie = fbCookie.webCookie, - text = html - )?.data - } - .distinctUntilChanged() - .flowOn(Dispatchers.IO) - .onEach { data -> - L.v { "Badges $data" } - tabsForEachView { _, view -> - when (view.iicon) { - FbItem.FEED.icon -> view.badgeText = data.feed - FbItem.FRIENDS.icon -> view.badgeText = data.friends - FbItem.MESSAGES.icon -> view.badgeText = data.messages - FbItem.NOTIFICATIONS.icon -> view.badgeText = data.notifications - } - } - } - .flowOn(Dispatchers.Main) - .launchIn(this@MainActivity) - } + override fun onTabSelected(tab: TabLayout.Tab) { + super.onTabSelected(tab) + (tab.customView as BadgedIcon).badgeText = null + } + } + ) + headerFlow + .filter { it.isNotBlank() } + .mapNotNull { html -> + BadgeParser.parseFromData(cookie = fbCookie.webCookie, text = html)?.data + } + .distinctUntilChanged() + .flowOn(Dispatchers.IO) + .onEach { data -> + L.v { "Badges $data" } + tabsForEachView { _, view -> + when (view.iicon) { + FbItem.FEED.icon -> view.badgeText = data.feed + FbItem.FRIENDS.icon -> view.badgeText = data.friends + FbItem.MESSAGES.icon -> view.badgeText = data.messages + FbItem.NOTIFICATIONS.icon -> view.badgeText = data.notifications + } + } + } + .flowOn(Dispatchers.Main) + .launchIn(this@MainActivity) + } } diff --git a/app/src/main/kotlin/com/pitchedapps/frost/activities/SelectorActivity.kt b/app/src/main/kotlin/com/pitchedapps/frost/activities/SelectorActivity.kt index a891c7c95..a74715e82 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/activities/SelectorActivity.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/activities/SelectorActivity.kt @@ -32,43 +32,44 @@ import com.pitchedapps.frost.utils.launchNewTask import com.pitchedapps.frost.views.AccountItem import kotlinx.coroutines.launch -/** - * Created by Allan Wang on 2017-06-04. - */ +/** Created by Allan Wang on 2017-06-04. */ class SelectorActivity : BaseActivity() { - val recycler: RecyclerView by bindView(R.id.selector_recycler) - val adapter = FastItemAdapter() - val text: AppCompatTextView by bindView(R.id.text_select_account) - val container: ConstraintLayout by bindView(R.id.container) + val recycler: RecyclerView by bindView(R.id.selector_recycler) + val adapter = FastItemAdapter() + val text: AppCompatTextView by bindView(R.id.text_select_account) + val container: ConstraintLayout by bindView(R.id.container) - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - setContentView(R.layout.activity_selector) - recycler.layoutManager = GridLayoutManager(this, 2) - recycler.adapter = adapter - adapter.add(cookies().map { AccountItem(it, themeProvider) }) - adapter.add(AccountItem(null, themeProvider)) // add account - adapter.addEventHook(object : ClickEventHook() { - override fun onBind(viewHolder: RecyclerView.ViewHolder): View? = - (viewHolder as? AccountItem.ViewHolder)?.itemView + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(R.layout.activity_selector) + recycler.layoutManager = GridLayoutManager(this, 2) + recycler.adapter = adapter + adapter.add(cookies().map { AccountItem(it, themeProvider) }) + adapter.add(AccountItem(null, themeProvider)) // add account + adapter.addEventHook( + object : ClickEventHook() { + override fun onBind(viewHolder: RecyclerView.ViewHolder): View? = + (viewHolder as? AccountItem.ViewHolder)?.itemView - override fun onClick( - v: View, - position: Int, - fastAdapter: FastAdapter, - item: AccountItem - ) { - if (item.cookie == null) this@SelectorActivity.launchNewTask() - else launch { - fbCookie.switchUser(item.cookie) - launchNewTask(cookies()) - } + override fun onClick( + v: View, + position: Int, + fastAdapter: FastAdapter, + item: AccountItem + ) { + if (item.cookie == null) this@SelectorActivity.launchNewTask() + else + launch { + fbCookie.switchUser(item.cookie) + launchNewTask(cookies()) } - }) - activityThemer.setFrostColors { - text(text) - background(container) } + } + ) + activityThemer.setFrostColors { + text(text) + background(container) } + } } diff --git a/app/src/main/kotlin/com/pitchedapps/frost/activities/SettingsActivity.kt b/app/src/main/kotlin/com/pitchedapps/frost/activities/SettingsActivity.kt index fb3872df3..952bed903 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/activities/SettingsActivity.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/activities/SettingsActivity.kt @@ -59,225 +59,206 @@ import com.pitchedapps.frost.utils.frostNavigationBar import com.pitchedapps.frost.utils.launchNewTask import com.pitchedapps.frost.utils.loadAssets import dagger.hilt.android.AndroidEntryPoint +import javax.inject.Inject import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.launch -import javax.inject.Inject -/** - * Created by Allan Wang on 2017-06-06. - */ +/** Created by Allan Wang on 2017-06-06. */ @AndroidEntryPoint class SettingsActivity : KPrefActivity() { - @Inject - lateinit var fbCookie: FbCookie + @Inject lateinit var fbCookie: FbCookie - @Inject - lateinit var prefs: Prefs + @Inject lateinit var prefs: Prefs - @Inject - lateinit var themeProvider: ThemeProvider + @Inject lateinit var themeProvider: ThemeProvider - @Inject - lateinit var notifDao: NotificationDao + @Inject lateinit var notifDao: NotificationDao - @Inject - lateinit var activityThemer: ActivityThemer + @Inject lateinit var activityThemer: ActivityThemer - private var resultFlag = Activity.RESULT_CANCELED + private var resultFlag = Activity.RESULT_CANCELED - companion object { - private const val REQUEST_RINGTONE = 0b10111 shl 5 - const val REQUEST_NOTIFICATION_RINGTONE = REQUEST_RINGTONE or 1 - const val REQUEST_MESSAGE_RINGTONE = REQUEST_RINGTONE or 2 - const val ACTIVITY_REQUEST_TABS = 29 - const val ACTIVITY_REQUEST_DEBUG = 53 + companion object { + private const val REQUEST_RINGTONE = 0b10111 shl 5 + const val REQUEST_NOTIFICATION_RINGTONE = REQUEST_RINGTONE or 1 + const val REQUEST_MESSAGE_RINGTONE = REQUEST_RINGTONE or 2 + const val ACTIVITY_REQUEST_TABS = 29 + const val ACTIVITY_REQUEST_DEBUG = 53 + } + + override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { + super.onActivityResult(requestCode, resultCode, data) + if (fetchRingtone(requestCode, resultCode, data)) return + when (requestCode) { + ACTIVITY_REQUEST_TABS -> { + if (resultCode == Activity.RESULT_OK) shouldRestartMain() + return + } + ACTIVITY_REQUEST_DEBUG -> { + val url = data?.extras?.getString(DebugActivity.RESULT_URL) + if (resultCode == Activity.RESULT_OK && url?.isNotBlank() == true) + sendDebug(url, data.getStringExtra(DebugActivity.RESULT_BODY)) + return + } + } + reloadList() + } + + /** Fetch ringtone and save uri Returns [true] if consumed, [false] otherwise */ + private fun fetchRingtone(requestCode: Int, resultCode: Int, data: Intent?): Boolean { + if (requestCode and REQUEST_RINGTONE != REQUEST_RINGTONE || resultCode != Activity.RESULT_OK) + return false + val uri = data?.getParcelableExtra(RingtoneManager.EXTRA_RINGTONE_PICKED_URI) + val uriString: String = uri?.toString() ?: "" + if (uri != null) { + try { + grantUriPermission("com.android.systemui", uri, Intent.FLAG_GRANT_READ_URI_PERMISSION) + } catch (e: Exception) { + L.e(e) { "grantUriPermission" } + } + } + when (requestCode) { + REQUEST_NOTIFICATION_RINGTONE -> { + prefs.notificationRingtone = uriString + reloadByTitle(R.string.notification_ringtone) + } + REQUEST_MESSAGE_RINGTONE -> { + prefs.messageRingtone = uriString + reloadByTitle(R.string.message_ringtone) + } + } + return true + } + + override fun kPrefCoreAttributes(): CoreAttributeContract.() -> Unit = { + textColor = { themeProvider.textColor } + accentColor = { themeProvider.accentColor } + } + + override fun onCreateKPrefs(savedInstanceState: Bundle?): KPrefAdapterBuilder.() -> Unit = { + subItems(R.string.appearance, getAppearancePrefs()) { + descRes = R.string.appearance_desc + iicon = GoogleMaterial.Icon.gmd_palette } - override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { - super.onActivityResult(requestCode, resultCode, data) - if (fetchRingtone(requestCode, resultCode, data)) return - when (requestCode) { - ACTIVITY_REQUEST_TABS -> { - if (resultCode == Activity.RESULT_OK) - shouldRestartMain() - return - } - ACTIVITY_REQUEST_DEBUG -> { - val url = data?.extras?.getString(DebugActivity.RESULT_URL) - if (resultCode == Activity.RESULT_OK && url?.isNotBlank() == true) - sendDebug(url, data.getStringExtra(DebugActivity.RESULT_BODY)) - return - } - } - reloadList() + subItems(R.string.behaviour, getBehaviourPrefs()) { + descRes = R.string.behaviour_desc + iicon = GoogleMaterial.Icon.gmd_trending_up } - /** - * Fetch ringtone and save uri - * Returns [true] if consumed, [false] otherwise - */ - private fun fetchRingtone(requestCode: Int, resultCode: Int, data: Intent?): Boolean { - if (requestCode and REQUEST_RINGTONE != REQUEST_RINGTONE || resultCode != Activity.RESULT_OK) return false - val uri = data?.getParcelableExtra(RingtoneManager.EXTRA_RINGTONE_PICKED_URI) - val uriString: String = uri?.toString() ?: "" - if (uri != null) { - try { - grantUriPermission( - "com.android.systemui", - uri, - Intent.FLAG_GRANT_READ_URI_PERMISSION - ) - } catch (e: Exception) { - L.e(e) { "grantUriPermission" } - } - } - when (requestCode) { - REQUEST_NOTIFICATION_RINGTONE -> { - prefs.notificationRingtone = uriString - reloadByTitle(R.string.notification_ringtone) - } - REQUEST_MESSAGE_RINGTONE -> { - prefs.messageRingtone = uriString - reloadByTitle(R.string.message_ringtone) - } - } - return true + subItems(R.string.newsfeed, getFeedPrefs()) { + descRes = R.string.newsfeed_desc + iicon = CommunityMaterial.Icon3.cmd_newspaper } - override fun kPrefCoreAttributes(): CoreAttributeContract.() -> Unit = { - textColor = { themeProvider.textColor } - accentColor = { themeProvider.accentColor } + subItems(R.string.notifications, getNotificationPrefs()) { + descRes = R.string.notifications_desc + iicon = GoogleMaterial.Icon.gmd_notifications } - override fun onCreateKPrefs(savedInstanceState: Bundle?): KPrefAdapterBuilder.() -> Unit = { - subItems(R.string.appearance, getAppearancePrefs()) { - descRes = R.string.appearance_desc - iicon = GoogleMaterial.Icon.gmd_palette - } - - subItems(R.string.behaviour, getBehaviourPrefs()) { - descRes = R.string.behaviour_desc - iicon = GoogleMaterial.Icon.gmd_trending_up - } - - subItems(R.string.newsfeed, getFeedPrefs()) { - descRes = R.string.newsfeed_desc - iicon = CommunityMaterial.Icon3.cmd_newspaper - } - - subItems(R.string.notifications, getNotificationPrefs()) { - descRes = R.string.notifications_desc - iicon = GoogleMaterial.Icon.gmd_notifications - } - - subItems(R.string.security, getSecurityPrefs()) { - descRes = R.string.security_desc - iicon = GoogleMaterial.Icon.gmd_lock - } - -// subItems(R.string.network, getNetworkPrefs()) { -// descRes = R.string.network_desc -// iicon = GoogleMaterial.Icon.gmd_network_cell -// } - - // todo add donation? - - plainText(R.string.about_frost) { - descRes = R.string.about_frost_desc - iicon = GoogleMaterial.Icon.gmd_info - onClick = { - startActivityForResult( - 9, - bundleBuilder = { - withSceneTransitionAnimation(this@SettingsActivity) - } - ) - } - } - - plainText(R.string.help_translate) { - descRes = R.string.help_translate_desc - iicon = GoogleMaterial.Icon.gmd_translate - onClick = { startLink(R.string.translation_url) } - } - - plainText(R.string.replay_intro) { - iicon = GoogleMaterial.Icon.gmd_replay - onClick = { launchNewTask(cookies(), true) } - } - - subItems(R.string.experimental, getExperimentalPrefs()) { - descRes = R.string.experimental_desc - iicon = CommunityMaterial.Icon2.cmd_flask_outline - } - - subItems(R.string.debug_frost, getDebugPrefs()) { - descRes = R.string.debug_frost_desc - iicon = CommunityMaterial.Icon.cmd_bug - visible = { prefs.debugSettings } - } + subItems(R.string.security, getSecurityPrefs()) { + descRes = R.string.security_desc + iicon = GoogleMaterial.Icon.gmd_lock } - fun setFrostResult(flag: Int) { - resultFlag = resultFlag or flag - } + // subItems(R.string.network, getNetworkPrefs()) { + // descRes = R.string.network_desc + // iicon = GoogleMaterial.Icon.gmd_network_cell + // } - fun shouldRestartMain() { - setFrostResult(REQUEST_RESTART) - } + // todo add donation? - fun shouldRefreshMain() { - setFrostResult(REQUEST_REFRESH) - } - - @SuppressLint("MissingSuperCall") - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - activityThemer.setFrostTheme(forceTransparent = true) - animate = prefs.animate - themeExterior(false) - } - - fun themeExterior(animate: Boolean = true) { - if (animate) bgCanvas.fade(themeProvider.bgColor) - else bgCanvas.set(themeProvider.bgColor) - if (animate) toolbarCanvas.ripple( - themeProvider.headerColor, - RippleCanvas.MIDDLE, - RippleCanvas.END + plainText(R.string.about_frost) { + descRes = R.string.about_frost_desc + iicon = GoogleMaterial.Icon.gmd_info + onClick = { + startActivityForResult( + 9, + bundleBuilder = { withSceneTransitionAnimation(this@SettingsActivity) } ) - else toolbarCanvas.set(themeProvider.headerColor) - frostNavigationBar(prefs, themeProvider) + } } - override fun onBackPressed() { - if (!super.backPress()) { - setResult(resultFlag) - launch(NonCancellable) { - loadAssets(themeProvider) - finishSlideOut() - } - } + plainText(R.string.help_translate) { + descRes = R.string.help_translate_desc + iicon = GoogleMaterial.Icon.gmd_translate + onClick = { startLink(R.string.translation_url) } } - override fun onCreateOptionsMenu(menu: Menu): Boolean { - menuInflater.inflate(R.menu.menu_settings, menu) - toolbar.tint(themeProvider.iconColor) - setMenuIcons( - menu, themeProvider.iconColor, - R.id.action_github to CommunityMaterial.Icon2.cmd_github, - R.id.action_changelog to GoogleMaterial.Icon.gmd_info - ) - return true + plainText(R.string.replay_intro) { + iicon = GoogleMaterial.Icon.gmd_replay + onClick = { launchNewTask(cookies(), true) } } - override fun onOptionsItemSelected(item: MenuItem): Boolean { - when (item.itemId) { - R.id.action_github -> startLink(R.string.github_url) - R.id.action_changelog -> frostChangelog() - else -> return super.onOptionsItemSelected(item) - } - return true + subItems(R.string.experimental, getExperimentalPrefs()) { + descRes = R.string.experimental_desc + iicon = CommunityMaterial.Icon2.cmd_flask_outline } + + subItems(R.string.debug_frost, getDebugPrefs()) { + descRes = R.string.debug_frost_desc + iicon = CommunityMaterial.Icon.cmd_bug + visible = { prefs.debugSettings } + } + } + + fun setFrostResult(flag: Int) { + resultFlag = resultFlag or flag + } + + fun shouldRestartMain() { + setFrostResult(REQUEST_RESTART) + } + + fun shouldRefreshMain() { + setFrostResult(REQUEST_REFRESH) + } + + @SuppressLint("MissingSuperCall") + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + activityThemer.setFrostTheme(forceTransparent = true) + animate = prefs.animate + themeExterior(false) + } + + fun themeExterior(animate: Boolean = true) { + if (animate) bgCanvas.fade(themeProvider.bgColor) else bgCanvas.set(themeProvider.bgColor) + if (animate) + toolbarCanvas.ripple(themeProvider.headerColor, RippleCanvas.MIDDLE, RippleCanvas.END) + else toolbarCanvas.set(themeProvider.headerColor) + frostNavigationBar(prefs, themeProvider) + } + + override fun onBackPressed() { + if (!super.backPress()) { + setResult(resultFlag) + launch(NonCancellable) { + loadAssets(themeProvider) + finishSlideOut() + } + } + } + + override fun onCreateOptionsMenu(menu: Menu): Boolean { + menuInflater.inflate(R.menu.menu_settings, menu) + toolbar.tint(themeProvider.iconColor) + setMenuIcons( + menu, + themeProvider.iconColor, + R.id.action_github to CommunityMaterial.Icon2.cmd_github, + R.id.action_changelog to GoogleMaterial.Icon.gmd_info + ) + return true + } + + override fun onOptionsItemSelected(item: MenuItem): Boolean { + when (item.itemId) { + R.id.action_github -> startLink(R.string.github_url) + R.id.action_changelog -> frostChangelog() + else -> return super.onOptionsItemSelected(item) + } + return true + } } diff --git a/app/src/main/kotlin/com/pitchedapps/frost/activities/TabCustomizerActivity.kt b/app/src/main/kotlin/com/pitchedapps/frost/activities/TabCustomizerActivity.kt index adf543dfc..c4d9e4604 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/activities/TabCustomizerActivity.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/activities/TabCustomizerActivity.kt @@ -43,119 +43,116 @@ import com.pitchedapps.frost.facebook.FbItem import com.pitchedapps.frost.iitems.TabIItem import com.pitchedapps.frost.utils.L import dagger.hilt.android.AndroidEntryPoint -import kotlinx.coroutines.NonCancellable -import kotlinx.coroutines.launch import java.util.Collections import javax.inject.Inject +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.launch -/** - * Created by Allan Wang on 26/11/17. - */ +/** Created by Allan Wang on 26/11/17. */ @AndroidEntryPoint class TabCustomizerActivity : BaseActivity() { - @Inject - lateinit var genericDao: GenericDao + @Inject lateinit var genericDao: GenericDao - private val adapter = FastItemAdapter() + private val adapter = FastItemAdapter() - private val wobble = lazyContext { AnimationUtils.loadAnimation(it, R.anim.rotate_delta) } + private val wobble = lazyContext { AnimationUtils.loadAnimation(it, R.anim.rotate_delta) } - private lateinit var binding: ActivityTabCustomizerBinding + private lateinit var binding: ActivityTabCustomizerBinding - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - binding = ActivityTabCustomizerBinding.inflate(layoutInflater) - setContentView(binding.root) - binding.init() + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + binding = ActivityTabCustomizerBinding.inflate(layoutInflater) + setContentView(binding.root) + binding.init() + } + + fun ActivityTabCustomizerBinding.init() { + pseudoToolbar.setBackgroundColor(themeProvider.headerColor) + + tabRecycler.layoutManager = + GridLayoutManager(this@TabCustomizerActivity, TAB_COUNT, RecyclerView.VERTICAL, false) + tabRecycler.adapter = adapter + tabRecycler.setHasFixedSize(true) + + divider.setBackgroundColor(themeProvider.textColor.withAlpha(30)) + instructions.setTextColor(themeProvider.textColor) + + launch { + val tabs = genericDao.getTabs().toMutableList() + L.d { "Tabs $tabs" } + val remaining = FbItem.values().filter { it.name[0] != '_' }.toMutableList() + remaining.removeAll(tabs) + tabs.addAll(remaining) + adapter.set(tabs.map { TabIItem(it, themeProvider) }) + + bindSwapper(adapter, tabRecycler) + + adapter.onClickListener = { view, _, _, _ -> + view!!.wobble() + true + } } - fun ActivityTabCustomizerBinding.init() { - pseudoToolbar.setBackgroundColor(themeProvider.headerColor) + setResult(Activity.RESULT_CANCELED) - tabRecycler.layoutManager = - GridLayoutManager(this@TabCustomizerActivity, TAB_COUNT, RecyclerView.VERTICAL, false) - tabRecycler.adapter = adapter - tabRecycler.setHasFixedSize(true) + fabSave.setIcon(GoogleMaterial.Icon.gmd_check, themeProvider.iconColor) + fabSave.backgroundTintList = ColorStateList.valueOf(themeProvider.accentColor) + fabSave.setOnClickListener { + launchMain(NonCancellable) { + val tabs = adapter.adapterItems.subList(0, TAB_COUNT).map(TabIItem::item) + genericDao.saveTabs(tabs) + setResult(Activity.RESULT_OK) + finish() + } + } + fabCancel.setIcon(GoogleMaterial.Icon.gmd_close, themeProvider.iconColor) + fabCancel.backgroundTintList = ColorStateList.valueOf(themeProvider.accentColor) + fabCancel.setOnClickListener { finish() } + activityThemer.setFrostColors { themeWindow = true } + } - divider.setBackgroundColor(themeProvider.textColor.withAlpha(30)) - instructions.setTextColor(themeProvider.textColor) + private fun View.wobble() = startAnimation(wobble(context)) - launch { - val tabs = genericDao.getTabs().toMutableList() - L.d { "Tabs $tabs" } - val remaining = FbItem.values().filter { it.name[0] != '_' }.toMutableList() - remaining.removeAll(tabs) - tabs.addAll(remaining) - adapter.set(tabs.map { TabIItem(it, themeProvider) }) + private fun bindSwapper(adapter: FastItemAdapter<*>, recycler: RecyclerView) { + val dragCallback = TabDragCallback(SimpleDragCallback.ALL, swapper(adapter)) + ItemTouchHelper(dragCallback).attachToRecyclerView(recycler) + } - bindSwapper(adapter, tabRecycler) + private fun swapper(adapter: FastItemAdapter<*>) = + object : ItemTouchCallback { + override fun itemTouchOnMove(oldPosition: Int, newPosition: Int): Boolean { + Collections.swap(adapter.adapterItems, oldPosition, newPosition) + adapter.notifyAdapterDataSetChanged() + return true + } - adapter.onClickListener = { view, _, _, _ -> view!!.wobble(); true } - } - - setResult(Activity.RESULT_CANCELED) - - fabSave.setIcon(GoogleMaterial.Icon.gmd_check, themeProvider.iconColor) - fabSave.backgroundTintList = ColorStateList.valueOf(themeProvider.accentColor) - fabSave.setOnClickListener { - launchMain(NonCancellable) { - val tabs = adapter.adapterItems.subList(0, TAB_COUNT).map(TabIItem::item) - genericDao.saveTabs(tabs) - setResult(Activity.RESULT_OK) - finish() - } - } - fabCancel.setIcon(GoogleMaterial.Icon.gmd_close, themeProvider.iconColor) - fabCancel.backgroundTintList = ColorStateList.valueOf(themeProvider.accentColor) - fabCancel.setOnClickListener { finish() } - activityThemer.setFrostColors { - themeWindow = true - } + override fun itemTouchDropped(oldPosition: Int, newPosition: Int) = Unit } - private fun View.wobble() = startAnimation(wobble(context)) + private class TabDragCallback(directions: Int, itemTouchCallback: ItemTouchCallback) : + SimpleDragCallback(directions, itemTouchCallback) { - private fun bindSwapper(adapter: FastItemAdapter<*>, recycler: RecyclerView) { - val dragCallback = TabDragCallback(SimpleDragCallback.ALL, swapper(adapter)) - ItemTouchHelper(dragCallback).attachToRecyclerView(recycler) - } + private var draggingView: TabIItem.ViewHolder? = null - private fun swapper(adapter: FastItemAdapter<*>) = object : ItemTouchCallback { - override fun itemTouchOnMove(oldPosition: Int, newPosition: Int): Boolean { - Collections.swap(adapter.adapterItems, oldPosition, newPosition) - adapter.notifyAdapterDataSetChanged() - return true + override fun onSelectedChanged(viewHolder: RecyclerView.ViewHolder?, actionState: Int) { + super.onSelectedChanged(viewHolder, actionState) + when (actionState) { + ItemTouchHelper.ACTION_STATE_DRAG -> { + (viewHolder as? TabIItem.ViewHolder)?.apply { + draggingView = this + itemView.animate().scaleXY(1.3f) + text.animate().alpha(0f) + } } - - override fun itemTouchDropped(oldPosition: Int, newPosition: Int) = Unit - } - - private class TabDragCallback( - directions: Int, - itemTouchCallback: ItemTouchCallback - ) : SimpleDragCallback(directions, itemTouchCallback) { - - private var draggingView: TabIItem.ViewHolder? = null - - override fun onSelectedChanged(viewHolder: RecyclerView.ViewHolder?, actionState: Int) { - super.onSelectedChanged(viewHolder, actionState) - when (actionState) { - ItemTouchHelper.ACTION_STATE_DRAG -> { - (viewHolder as? TabIItem.ViewHolder)?.apply { - draggingView = this - itemView.animate().scaleXY(1.3f) - text.animate().alpha(0f) - } - } - ItemTouchHelper.ACTION_STATE_IDLE -> { - draggingView?.apply { - itemView.animate().scaleXY(1f) - text.animate().alpha(1f) - } - draggingView = null - } - } + ItemTouchHelper.ACTION_STATE_IDLE -> { + draggingView?.apply { + itemView.animate().scaleXY(1f) + text.animate().alpha(1f) + } + draggingView = null } + } } + } } diff --git a/app/src/main/kotlin/com/pitchedapps/frost/activities/WebOverlayActivity.kt b/app/src/main/kotlin/com/pitchedapps/frost/activities/WebOverlayActivity.kt index 8dbf9d5c4..66ffdd8d7 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/activities/WebOverlayActivity.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/activities/WebOverlayActivity.kt @@ -64,6 +64,7 @@ import com.pitchedapps.frost.views.FrostContentWeb import com.pitchedapps.frost.views.FrostVideoViewer import com.pitchedapps.frost.views.FrostWebView import dagger.hilt.android.AndroidEntryPoint +import javax.inject.Inject import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.launchIn @@ -71,7 +72,6 @@ import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.take import kotlinx.coroutines.launch import okhttp3.HttpUrl.Companion.toHttpUrlOrNull -import javax.inject.Inject /** * Created by Allan Wang on 2017-06-01. @@ -83,243 +83,236 @@ import javax.inject.Inject */ /** - * Used by notifications. Unlike the other overlays, this runs as a singleInstance - * Going back will bring you back to the previous app + * Used by notifications. Unlike the other overlays, this runs as a singleInstance Going back will + * bring you back to the previous app */ class FrostWebActivity : WebOverlayActivityBase() { - override fun onCreate(savedInstanceState: Bundle?) { - val requiresAction = !parseActionSend() - super.onCreate(savedInstanceState) - if (requiresAction) { - /* - * Signifies that we need to let the user know of a bad url - * We will subscribe to the load cycle once, - * and pop a dialog giving the user the option to copy the shared text - */ - content.scope.launch(Dispatchers.IO) { - content.refreshFlow.take(1).collect() - withMainContext { - materialDialog { - title(R.string.invalid_share_url) - message(R.string.invalid_share_url_desc) - } - } - } + override fun onCreate(savedInstanceState: Bundle?) { + val requiresAction = !parseActionSend() + super.onCreate(savedInstanceState) + if (requiresAction) { + /* + * Signifies that we need to let the user know of a bad url + * We will subscribe to the load cycle once, + * and pop a dialog giving the user the option to copy the shared text + */ + content.scope.launch(Dispatchers.IO) { + content.refreshFlow.take(1).collect() + withMainContext { + materialDialog { + title(R.string.invalid_share_url) + message(R.string.invalid_share_url_desc) + } } + } } + } - /** - * Attempts to parse the action url - * Returns [true] if no action exists or if the action has been consumed, [false] if we need to notify the user of a bad action - */ - private fun parseActionSend(): Boolean { - if (intent.action != Intent.ACTION_SEND || intent.type != "text/plain") return true - val text = intent.getStringExtra(Intent.EXTRA_TEXT) ?: return true - val url = text.toHttpUrlOrNull()?.toString() - return if (url == null) { - L.i { "Attempted to share a non-url" } - L._i { "Shared text: $text" } - copyToClipboard(text, "Text to Share", showToast = false) - intent.putExtra(ARG_URL, FbItem.FEED.url) - false - } else { - L.i { "Sharing url through overlay" } - L._i { "Url: $url" } - intent.putExtra(ARG_URL, "${FB_URL_BASE}sharer/sharer.php?u=$url") - true - } + /** + * Attempts to parse the action url Returns [true] if no action exists or if the action has been + * consumed, [false] if we need to notify the user of a bad action + */ + private fun parseActionSend(): Boolean { + if (intent.action != Intent.ACTION_SEND || intent.type != "text/plain") return true + val text = intent.getStringExtra(Intent.EXTRA_TEXT) ?: return true + val url = text.toHttpUrlOrNull()?.toString() + return if (url == null) { + L.i { "Attempted to share a non-url" } + L._i { "Shared text: $text" } + copyToClipboard(text, "Text to Share", showToast = false) + intent.putExtra(ARG_URL, FbItem.FEED.url) + false + } else { + L.i { "Sharing url through overlay" } + L._i { "Url: $url" } + intent.putExtra(ARG_URL, "${FB_URL_BASE}sharer/sharer.php?u=$url") + true } + } } /** - * Variant that forces a mobile user agent. This is largely internal, - * and is only necessary when we are launching from an existing [WebOverlayActivityBase] + * Variant that forces a mobile user agent. This is largely internal, and is only necessary when we + * are launching from an existing [WebOverlayActivityBase] */ class WebOverlayMobileActivity : WebOverlayActivityBase(USER_AGENT_MOBILE_CONST) /** - * Variant that forces a desktop user agent. This is largely internal, - * and is only necessary when we are launching from an existing [WebOverlayActivityBase] + * Variant that forces a desktop user agent. This is largely internal, and is only necessary when we + * are launching from an existing [WebOverlayActivityBase] */ class WebOverlayDesktopActivity : WebOverlayActivityBase(USER_AGENT_DESKTOP_CONST) /** - * Internal overlay for the app; this is tied with the main task and is singleTop as opposed to singleInstance + * Internal overlay for the app; this is tied with the main task and is singleTop as opposed to + * singleInstance */ class WebOverlayActivity : WebOverlayActivityBase() @AndroidEntryPoint abstract class WebOverlayActivityBase(private val userAgent: String = USER_AGENT) : - BaseActivity(), - FrostContentContainer, - VideoViewHolder { + BaseActivity(), FrostContentContainer, VideoViewHolder { - override val frameWrapper: FrameLayout by bindView(R.id.frame_wrapper) - val toolbar: Toolbar by bindView(R.id.overlay_toolbar) - val content: FrostContentWeb by bindView(R.id.frost_content_web) - val web: FrostWebView - get() = content.coreView - private val coordinator: CoordinatorLayout by bindView(R.id.overlay_main_content) + override val frameWrapper: FrameLayout by bindView(R.id.frame_wrapper) + val toolbar: Toolbar by bindView(R.id.overlay_toolbar) + val content: FrostContentWeb by bindView(R.id.frost_content_web) + val web: FrostWebView + get() = content.coreView + private val coordinator: CoordinatorLayout by bindView(R.id.overlay_main_content) - @Inject - lateinit var webFileChooser: WebFileChooser + @Inject lateinit var webFileChooser: WebFileChooser - private inline val urlTest: String? - get() = intent.getStringExtra(ARG_URL) ?: intent.dataString + private inline val urlTest: String? + get() = intent.getStringExtra(ARG_URL) ?: intent.dataString - lateinit var swipeBack: SwipeBackContract + lateinit var swipeBack: SwipeBackContract - /** - * Nonnull variant; verify by checking [urlTest] - */ - override val baseUrl: String - get() = urlTest!!.formattedFbUrl + /** Nonnull variant; verify by checking [urlTest] */ + override val baseUrl: String + get() = urlTest!!.formattedFbUrl - override val baseEnum: FbItem? = null + override val baseEnum: FbItem? = null - private inline val userId: Long - get() = intent.getLongExtra(ARG_USER_ID, prefs.userId) + private inline val userId: Long + get() = intent.getLongExtra(ARG_USER_ID, prefs.userId) - private val overlayContext: OverlayContext? - get() = OverlayContext[intent.extras] + private val overlayContext: OverlayContext? + get() = OverlayContext[intent.extras] - override fun setTitle(title: String) { - toolbar.title = title + override fun setTitle(title: String) { + toolbar.title = title + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + if (urlTest == null) { + L.e { "Empty link on web overlay" } + toast(R.string.null_url_overlay) + finish() + return } + setFrameContentView(R.layout.activity_web_overlay) + setSupportActionBar(toolbar) + supportActionBar?.setDisplayShowHomeEnabled(true) + supportActionBar?.setDisplayHomeAsUpEnabled(true) + toolbar.navigationIcon = + GoogleMaterial.Icon.gmd_close.toDrawable(this, 16, themeProvider.iconColor) + toolbar.setNavigationOnClickListener { finishSlideOut() } - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - if (urlTest == null) { - L.e { "Empty link on web overlay" } - toast(R.string.null_url_overlay) - finish() - return + activityThemer.setFrostColors { + toolbar(toolbar) + themeWindow = false + } + coordinator.setBackgroundColor(themeProvider.bgColor.withAlpha(255)) + + content.bind(this) + + content.titleFlow.onEach { toolbar.title = it }.launchIn(this) + + with(web) { + userAgentString = userAgent + prefs.prevId = prefs.userId + launch { + val authDefer = BiometricUtils.authenticate(this@WebOverlayActivityBase, prefs) + if (userId != prefs.userId) { + fbCookie.switchUser(userId) } - setFrameContentView(R.layout.activity_web_overlay) - setSupportActionBar(toolbar) - supportActionBar?.setDisplayShowHomeEnabled(true) - supportActionBar?.setDisplayHomeAsUpEnabled(true) - toolbar.navigationIcon = - GoogleMaterial.Icon.gmd_close.toDrawable(this, 16, themeProvider.iconColor) - toolbar.setNavigationOnClickListener { finishSlideOut() } - - activityThemer.setFrostColors { - toolbar(toolbar) - themeWindow = false - } - coordinator.setBackgroundColor(themeProvider.bgColor.withAlpha(255)) - - content.bind(this) - - content.titleFlow.onEach { toolbar.title = it }.launchIn(this) - - with(web) { - userAgentString = userAgent - prefs.prevId = prefs.userId - launch { - val authDefer = BiometricUtils.authenticate(this@WebOverlayActivityBase, prefs) - if (userId != prefs.userId) { - fbCookie.switchUser(userId) - } - authDefer.await() - reloadBase(true) - if (prefs.firstWebOverlay) { - coordinator.frostSnackbar(R.string.web_overlay_swipe_hint, themeProvider) { - duration = BaseTransientBottomBar.LENGTH_INDEFINITE - setAction(R.string.kau_got_it) { dismiss() } - } - } - } - } - - swipeBack = kauSwipeOnCreate { - if (!prefs.overlayFullScreenSwipe) edgeSize = 20.dpToPx - transitionSystemBars = false + authDefer.await() + reloadBase(true) + if (prefs.firstWebOverlay) { + coordinator.frostSnackbar(R.string.web_overlay_swipe_hint, themeProvider) { + duration = BaseTransientBottomBar.LENGTH_INDEFINITE + setAction(R.string.kau_got_it) { dismiss() } + } } + } } - /** - * Manage url loadings - * This is usually only called when multiple listeners are added and inject the same url - * We will avoid reloading if the url is the same - */ - override fun onNewIntent(intent: Intent) { - super.onNewIntent(intent) - L.d { "New intent" } - val newUrl = (intent.getStringExtra(ARG_URL) ?: intent.dataString)?.formattedFbUrl ?: return - if (baseUrl != newUrl) { - this.intent = intent - content.baseUrl = newUrl - web.reloadBase(true) - } + swipeBack = kauSwipeOnCreate { + if (!prefs.overlayFullScreenSwipe) edgeSize = 20.dpToPx + transitionSystemBars = false } + } - override fun backConsumer(): Boolean { - if (!web.onBackPressed()) - finishSlideOut() - return true + /** + * Manage url loadings This is usually only called when multiple listeners are added and inject + * the same url We will avoid reloading if the url is the same + */ + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + L.d { "New intent" } + val newUrl = (intent.getStringExtra(ARG_URL) ?: intent.dataString)?.formattedFbUrl ?: return + if (baseUrl != newUrl) { + this.intent = intent + content.baseUrl = newUrl + web.reloadBase(true) } + } - /** - * Our theme for the overlay should be fully opaque - */ - fun theme() { - val opaqueAccent = themeProvider.headerColor.withAlpha(255) - statusBarColor = opaqueAccent.darken() - navigationBarColor = opaqueAccent - toolbar.setBackgroundColor(opaqueAccent) - toolbar.setTitleTextColor(themeProvider.iconColor) - coordinator.setBackgroundColor(themeProvider.bgColor.withAlpha(255)) - toolbar.overflowIcon?.setTint(themeProvider.iconColor) + override fun backConsumer(): Boolean { + if (!web.onBackPressed()) finishSlideOut() + return true + } + + /** Our theme for the overlay should be fully opaque */ + fun theme() { + val opaqueAccent = themeProvider.headerColor.withAlpha(255) + statusBarColor = opaqueAccent.darken() + navigationBarColor = opaqueAccent + toolbar.setBackgroundColor(opaqueAccent) + toolbar.setTitleTextColor(themeProvider.iconColor) + coordinator.setBackgroundColor(themeProvider.bgColor.withAlpha(255)) + toolbar.overflowIcon?.setTint(themeProvider.iconColor) + } + + override fun onResume() { + super.onResume() + web.resumeTimers() + } + + override fun onPause() { + web.pauseTimers() + L.v { "Pause overlay web timers" } + super.onPause() + } + + override fun onDestroy() { + web.destroy() + super.onDestroy() + kauSwipeOnDestroy() + } + + override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { + if (webFileChooser.onActivityResultWeb(requestCode, resultCode, data)) return + super.onActivityResult(requestCode, resultCode, data) + } + + override fun onCreateOptionsMenu(menu: Menu): Boolean { + menuInflater.inflate(R.menu.menu_web, menu) + overlayContext?.onMenuCreate(this, menu) + toolbar.tint(themeProvider.iconColor) + return true + } + + override fun onOptionsItemSelected(item: MenuItem): Boolean { + val url = web.currentUrl.formattedFbUrl + when (item.itemId) { + R.id.action_copy_link -> copyToClipboard(url) + R.id.action_share -> shareText(url) + R.id.action_open_in_browser -> startLink(url) + else -> + if (!OverlayContext.onOptionsItemSelected(web, item.itemId)) + return super.onOptionsItemSelected(item) } + return true + } - override fun onResume() { - super.onResume() - web.resumeTimers() - } - - override fun onPause() { - web.pauseTimers() - L.v { "Pause overlay web timers" } - super.onPause() - } - - override fun onDestroy() { - web.destroy() - super.onDestroy() - kauSwipeOnDestroy() - } - - override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { - if (webFileChooser.onActivityResultWeb(requestCode, resultCode, data)) return - super.onActivityResult(requestCode, resultCode, data) - } - - override fun onCreateOptionsMenu(menu: Menu): Boolean { - menuInflater.inflate(R.menu.menu_web, menu) - overlayContext?.onMenuCreate(this, menu) - toolbar.tint(themeProvider.iconColor) - return true - } - - override fun onOptionsItemSelected(item: MenuItem): Boolean { - val url = web.currentUrl.formattedFbUrl - when (item.itemId) { - R.id.action_copy_link -> copyToClipboard(url) - R.id.action_share -> shareText(url) - R.id.action_open_in_browser -> startLink(url) - else -> if (!OverlayContext.onOptionsItemSelected(web, item.itemId)) - return super.onOptionsItemSelected(item) - } - return true - } - - /* - * ---------------------------------------------------- - * Video Contract - * ---------------------------------------------------- - */ - override var videoViewer: FrostVideoViewer? = null - override val lowerVideoPadding: PointF = PointF(0f, 0f) + /* + * ---------------------------------------------------- + * Video Contract + * ---------------------------------------------------- + */ + override var videoViewer: FrostVideoViewer? = null + override val lowerVideoPadding: PointF = PointF(0f, 0f) } diff --git a/app/src/main/kotlin/com/pitchedapps/frost/contracts/ActivityContract.kt b/app/src/main/kotlin/com/pitchedapps/frost/contracts/ActivityContract.kt index 2b7f7b2c8..f0be0f361 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/contracts/ActivityContract.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/contracts/ActivityContract.kt @@ -22,24 +22,22 @@ import com.pitchedapps.frost.web.FrostEmitter import kotlinx.coroutines.flow.SharedFlow interface MainActivityContract : MainFabContract { - val fragmentFlow: SharedFlow - val fragmentEmit: FrostEmitter + val fragmentFlow: SharedFlow + val fragmentEmit: FrostEmitter - val headerFlow: SharedFlow - val headerEmit: FrostEmitter + val headerFlow: SharedFlow + val headerEmit: FrostEmitter - fun setTitle(res: Int) - fun setTitle(text: CharSequence) + fun setTitle(res: Int) + fun setTitle(text: CharSequence) - /** - * Available on all threads - */ - fun collapseAppBar() + /** Available on all threads */ + fun collapseAppBar() - fun reloadFragment(fragment: BaseFragment) + fun reloadFragment(fragment: BaseFragment) } interface MainFabContract { - fun showFab(iicon: IIcon, clickEvent: () -> Unit) - fun hideFab() + fun showFab(iicon: IIcon, clickEvent: () -> Unit) + fun hideFab() } diff --git a/app/src/main/kotlin/com/pitchedapps/frost/contracts/DynamicUiContract.kt b/app/src/main/kotlin/com/pitchedapps/frost/contracts/DynamicUiContract.kt index 736ef72d4..76517d165 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/contracts/DynamicUiContract.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/contracts/DynamicUiContract.kt @@ -16,29 +16,18 @@ */ package com.pitchedapps.frost.contracts -/** - * Functions that will modify the current ui - */ +/** Functions that will modify the current ui */ interface DynamicUiContract { - /** - * Change all necessary view components to the new theme - * Also propagate where applicable - */ - fun reloadTheme() + /** Change all necessary view components to the new theme Also propagate where applicable */ + fun reloadTheme() - /** - * Change theme without propagation - */ - fun reloadThemeSelf() + /** Change theme without propagation */ + fun reloadThemeSelf() - /** - * Change text size & propagate - */ - fun reloadTextSize() + /** Change text size & propagate */ + fun reloadTextSize() - /** - * Change text size without propagation - */ - fun reloadTextSizeSelf() + /** Change text size without propagation */ + fun reloadTextSizeSelf() } diff --git a/app/src/main/kotlin/com/pitchedapps/frost/contracts/FileChooser.kt b/app/src/main/kotlin/com/pitchedapps/frost/contracts/FileChooser.kt index 521d0f97e..c2c5f1a1c 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/contracts/FileChooser.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/contracts/FileChooser.kt @@ -35,70 +35,58 @@ import dagger.hilt.android.components.ActivityComponent import dagger.hilt.android.scopes.ActivityScoped import javax.inject.Inject -/** - * Created by Allan Wang on 2017-07-04. - */ +/** Created by Allan Wang on 2017-07-04. */ private const val MEDIA_CHOOSER_RESULT = 67 interface WebFileChooser { - fun openMediaPicker( - filePathCallback: ValueCallback?>, - fileChooserParams: WebChromeClient.FileChooserParams - ) + fun openMediaPicker( + filePathCallback: ValueCallback?>, + fileChooserParams: WebChromeClient.FileChooserParams + ) - fun onActivityResultWeb( - requestCode: Int, - resultCode: Int, - intent: Intent? - ): Boolean + fun onActivityResultWeb(requestCode: Int, resultCode: Int, intent: Intent?): Boolean } -class WebFileChooserImpl @Inject internal constructor( - private val activity: Activity, - private val themeProvider: ThemeProvider -) : WebFileChooser { - private var filePathCallback: ValueCallback?>? = null +class WebFileChooserImpl +@Inject +internal constructor(private val activity: Activity, private val themeProvider: ThemeProvider) : + WebFileChooser { + private var filePathCallback: ValueCallback?>? = null - override fun openMediaPicker( - filePathCallback: ValueCallback?>, - fileChooserParams: WebChromeClient.FileChooserParams - ) { - activity.kauRequestPermissions(PERMISSION_WRITE_EXTERNAL_STORAGE) { granted, _ -> - if (!granted) { - L.d { "Failed to get write permissions" } - activity.frostSnackbar(R.string.file_chooser_not_found, themeProvider) - filePathCallback.onReceiveValue(null) - return@kauRequestPermissions - } - this.filePathCallback = filePathCallback - val intent = Intent() - intent.type = fileChooserParams.acceptTypes.firstOrNull() - intent.action = Intent.ACTION_GET_CONTENT - activity.startActivityForResult( - Intent.createChooser(intent, activity.string(R.string.pick_image)), - MEDIA_CHOOSER_RESULT - ) - } + override fun openMediaPicker( + filePathCallback: ValueCallback?>, + fileChooserParams: WebChromeClient.FileChooserParams + ) { + activity.kauRequestPermissions(PERMISSION_WRITE_EXTERNAL_STORAGE) { granted, _ -> + if (!granted) { + L.d { "Failed to get write permissions" } + activity.frostSnackbar(R.string.file_chooser_not_found, themeProvider) + filePathCallback.onReceiveValue(null) + return@kauRequestPermissions + } + this.filePathCallback = filePathCallback + val intent = Intent() + intent.type = fileChooserParams.acceptTypes.firstOrNull() + intent.action = Intent.ACTION_GET_CONTENT + activity.startActivityForResult( + Intent.createChooser(intent, activity.string(R.string.pick_image)), + MEDIA_CHOOSER_RESULT + ) } + } - override fun onActivityResultWeb( - requestCode: Int, - resultCode: Int, - intent: Intent? - ): Boolean { - L.d { "FileChooser On activity results web $requestCode" } - if (requestCode != MEDIA_CHOOSER_RESULT) return false - val data = intent?.data - filePathCallback?.onReceiveValue(if (data != null) arrayOf(data) else null) - filePathCallback = null - return true - } + override fun onActivityResultWeb(requestCode: Int, resultCode: Int, intent: Intent?): Boolean { + L.d { "FileChooser On activity results web $requestCode" } + if (requestCode != MEDIA_CHOOSER_RESULT) return false + val data = intent?.data + filePathCallback?.onReceiveValue(if (data != null) arrayOf(data) else null) + filePathCallback = null + return true + } } @Module @InstallIn(ActivityComponent::class) interface WebFileChooserModule { - @Binds - @ActivityScoped - fun webFileChooser(to: WebFileChooserImpl): WebFileChooser + @Binds @ActivityScoped fun webFileChooser(to: WebFileChooserImpl): WebFileChooser } diff --git a/app/src/main/kotlin/com/pitchedapps/frost/contracts/FrostContentContract.kt b/app/src/main/kotlin/com/pitchedapps/frost/contracts/FrostContentContract.kt index 7f91f901d..caff124d0 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/contracts/FrostContentContract.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/contracts/FrostContentContract.kt @@ -22,159 +22,112 @@ import com.pitchedapps.frost.web.FrostEmitter import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.SharedFlow -/** - * Created by Allan Wang on 20/12/17. - */ +/** Created by Allan Wang on 20/12/17. */ -/** - * Contract for the underlying parent, - * binds to activities & fragments - */ +/** Contract for the underlying parent, binds to activities & fragments */ interface FrostContentContainer : CoroutineScope { - val baseUrl: String + val baseUrl: String - val baseEnum: FbItem? + val baseEnum: FbItem? - /** - * Update toolbar title - */ - fun setTitle(title: String) + /** Update toolbar title */ + fun setTitle(title: String) } -/** - * Contract for components shared among - * all content providers - */ +/** Contract for components shared among all content providers */ interface FrostContentParent : DynamicUiContract { - val scope: CoroutineScope + val scope: CoroutineScope - val core: FrostContentCore + val core: FrostContentCore - /** - * Observable to get data on whether view is refreshing or not - */ - val refreshFlow: SharedFlow + /** Observable to get data on whether view is refreshing or not */ + val refreshFlow: SharedFlow - val refreshEmit: FrostEmitter + val refreshEmit: FrostEmitter - /** - * Observable to get data on refresh progress, with range [0, 100] - */ - val progressFlow: SharedFlow + /** Observable to get data on refresh progress, with range [0, 100] */ + val progressFlow: SharedFlow - val progressEmit: FrostEmitter + val progressEmit: FrostEmitter - /** - * Observable to get new title data (unique values only) - */ - val titleFlow: SharedFlow + /** Observable to get new title data (unique values only) */ + val titleFlow: SharedFlow - val titleEmit: FrostEmitter + val titleEmit: FrostEmitter - var baseUrl: String + var baseUrl: String - var baseEnum: FbItem? + var baseEnum: FbItem? - val swipeEnabled: Boolean get() = swipeAllowedByPage && !swipeDisabledByAction + val swipeEnabled: Boolean + get() = swipeAllowedByPage && !swipeDisabledByAction - /** - * Temporary disable swiping based on action - */ - var swipeDisabledByAction: Boolean + /** Temporary disable swiping based on action */ + var swipeDisabledByAction: Boolean - /** - * Decides if swipe should be allowed for the current page - */ - var swipeAllowedByPage: Boolean + /** Decides if swipe should be allowed for the current page */ + var swipeAllowedByPage: Boolean - /** - * Binds the container to self - * this will also handle all future bindings - * Must be called by container! - */ - fun bind(container: FrostContentContainer) + /** + * Binds the container to self this will also handle all future bindings Must be called by + * container! + */ + fun bind(container: FrostContentContainer) - /** - * Signal that the contract will not be used again - * Clean up resources where applicable - */ - fun destroy() + /** Signal that the contract will not be used again Clean up resources where applicable */ + fun destroy() - /** - * Hook onto the refresh observable for one cycle - * Animate toggles between the fancy ripple and the basic fade - * The cycle only starts on the first load since - * there may have been another process when this is registered - * - * Returns true to proceed with load - * In some cases when the url has not changed, - * it may not be advisable to proceed with the load - * For those cases, we will return false to stop it - */ - fun registerTransition(urlChanged: Boolean, animate: Boolean): Boolean + /** + * Hook onto the refresh observable for one cycle Animate toggles between the fancy ripple and the + * basic fade The cycle only starts on the first load since there may have been another process + * when this is registered + * + * Returns true to proceed with load In some cases when the url has not changed, it may not be + * advisable to proceed with the load For those cases, we will return false to stop it + */ + fun registerTransition(urlChanged: Boolean, animate: Boolean): Boolean } -/** - * Underlying contract for the content itself - */ +/** Underlying contract for the content itself */ interface FrostContentCore : DynamicUiContract { - val scope: CoroutineScope - get() = parent.scope + val scope: CoroutineScope + get() = parent.scope - /** - * Reference to parent - * Bound through calling [FrostContentParent.bind] - */ - val parent: FrostContentParent + /** Reference to parent Bound through calling [FrostContentParent.bind] */ + val parent: FrostContentParent - /** - * Initializes view through given [container] - * - * The content may be free to extract other data from - * the container if necessary - */ - fun bind(parent: FrostContentParent, container: FrostContentContainer): View + /** + * Initializes view through given [container] + * + * The content may be free to extract other data from the container if necessary + */ + fun bind(parent: FrostContentParent, container: FrostContentContainer): View - /** - * Call to reload wrapped data - */ - fun reload(animate: Boolean) + /** Call to reload wrapped data */ + fun reload(animate: Boolean) - /** - * Call to reload base data - */ - fun reloadBase(animate: Boolean) + /** Call to reload base data */ + fun reloadBase(animate: Boolean) - /** - * If possible, remove anything in the view stack - * Applies namely to webviews - */ - fun clearHistory() + /** If possible, remove anything in the view stack Applies namely to webviews */ + fun clearHistory() - /** - * Should be called when a back press is triggered - * Return [true] if consumed, [false] otherwise - */ - fun onBackPressed(): Boolean + /** + * Should be called when a back press is triggered Return [true] if consumed, [false] otherwise + */ + fun onBackPressed(): Boolean - val currentUrl: String + val currentUrl: String - /** - * Condition to help pause certain background resources - */ - var active: Boolean + /** Condition to help pause certain background resources */ + var active: Boolean - /** - * Triggered when view is within viewpager - * and tab is clicked - */ - fun onTabClicked() + /** Triggered when view is within viewpager and tab is clicked */ + fun onTabClicked() - /** - * Signal destruction to release some content manually - */ - fun destroy() + /** Signal destruction to release some content manually */ + fun destroy() } diff --git a/app/src/main/kotlin/com/pitchedapps/frost/contracts/FrostThemable.kt b/app/src/main/kotlin/com/pitchedapps/frost/contracts/FrostThemable.kt index 93d827a6c..0020b6bdd 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/contracts/FrostThemable.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/contracts/FrostThemable.kt @@ -22,23 +22,23 @@ import android.widget.TextView /** * Created by Allan Wang on 2017-11-07. * - * Should be implemented by all views in [com.pitchedapps.frost.activities.MainActivity] - * to allow for instant view reloading + * Should be implemented by all views in [com.pitchedapps.frost.activities.MainActivity] to allow + * for instant view reloading */ interface FrostThemable { - /** - * Change all necessary view components to the new theme - * and call whatever other children that also implement [FrostThemable] - */ - fun reloadTheme() + /** + * Change all necessary view components to the new theme and call whatever other children that + * also implement [FrostThemable] + */ + fun reloadTheme() - fun setTextColors(color: Int, vararg textViews: TextView?) = - themeViews(color, *textViews) { setTextColor(it) } + fun setTextColors(color: Int, vararg textViews: TextView?) = + themeViews(color, *textViews) { setTextColor(it) } - fun setBackgrounds(color: Int, vararg views: View?) = - themeViews(color, *views) { setBackgroundColor(it) } + fun setBackgrounds(color: Int, vararg views: View?) = + themeViews(color, *views) { setBackgroundColor(it) } - fun themeViews(color: Int, vararg views: T?, action: T.(Int) -> Unit) = - views.filterNotNull().forEach { it.action(color) } + fun themeViews(color: Int, vararg views: T?, action: T.(Int) -> Unit) = + views.filterNotNull().forEach { it.action(color) } } diff --git a/app/src/main/kotlin/com/pitchedapps/frost/contracts/VideoViewHolder.kt b/app/src/main/kotlin/com/pitchedapps/frost/contracts/VideoViewHolder.kt index e749b0d38..2ef90306b 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/contracts/VideoViewHolder.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/contracts/VideoViewHolder.kt @@ -24,45 +24,38 @@ import com.pitchedapps.frost.utils.L import com.pitchedapps.frost.views.FrostVideoContainerContract import com.pitchedapps.frost.views.FrostVideoViewer -/** - * Created by Allan Wang on 2017-11-10. - */ +/** Created by Allan Wang on 2017-11-10. */ interface VideoViewHolder : FrameWrapper, FrostVideoContainerContract { - var videoViewer: FrostVideoViewer? + var videoViewer: FrostVideoViewer? - fun showVideo(url: String) = showVideo(url, false) + fun showVideo(url: String) = showVideo(url, false) - /** - * Create new viewer and reuse existing one - * The url will be formatted upon loading - */ - fun showVideo(url: String, repeat: Boolean) { - if (videoViewer != null) - videoViewer?.setVideo(url, repeat) - else - videoViewer = FrostVideoViewer.showVideo(url, repeat, this) - } + /** Create new viewer and reuse existing one The url will be formatted upon loading */ + fun showVideo(url: String, repeat: Boolean) { + if (videoViewer != null) videoViewer?.setVideo(url, repeat) + else videoViewer = FrostVideoViewer.showVideo(url, repeat, this) + } - fun videoOnStop() = videoViewer?.pause() + fun videoOnStop() = videoViewer?.pause() - fun videoOnBackPress() = videoViewer?.onBackPressed() ?: false + fun videoOnBackPress() = videoViewer?.onBackPressed() ?: false - override val videoContainer: FrameLayout - get() = frameWrapper + override val videoContainer: FrameLayout + get() = frameWrapper - override fun onVideoFinished() { - L.d { "Video view released" } - videoViewer = null - } + override fun onVideoFinished() { + L.d { "Video view released" } + videoViewer = null + } } interface FrameWrapper { - val frameWrapper: FrameLayout + val frameWrapper: FrameLayout - fun Activity.setFrameContentView(layoutRes: Int) { - setContentView(R.layout.activity_frame_wrapper) - frameWrapper.inflate(layoutRes, true) - } + fun Activity.setFrameContentView(layoutRes: Int) { + setContentView(R.layout.activity_frame_wrapper) + frameWrapper.inflate(layoutRes, true) + } } diff --git a/app/src/main/kotlin/com/pitchedapps/frost/db/CacheDb.kt b/app/src/main/kotlin/com/pitchedapps/frost/db/CacheDb.kt index 68d71a91f..ada525817 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/db/CacheDb.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/db/CacheDb.kt @@ -26,63 +26,52 @@ import androidx.room.Query import com.pitchedapps.frost.utils.L import kotlinx.android.parcel.Parcelize -/** - * Created by Allan Wang on 2017-05-30. - */ +/** Created by Allan Wang on 2017-05-30. */ -/** - * Generic cache to store serialized content - */ +/** Generic cache to store serialized content */ @Entity( - tableName = "frost_cache", - primaryKeys = ["id", "type"], - foreignKeys = [ - ForeignKey( - entity = CookieEntity::class, - parentColumns = ["cookie_id"], - childColumns = ["id"], - onDelete = ForeignKey.CASCADE - ) - ] + tableName = "frost_cache", + primaryKeys = ["id", "type"], + foreignKeys = + [ + ForeignKey( + entity = CookieEntity::class, + parentColumns = ["cookie_id"], + childColumns = ["id"], + onDelete = ForeignKey.CASCADE + )] ) @Parcelize data class CacheEntity( - val id: Long, - val type: String, - val lastUpdated: Long, - val contents: String + val id: Long, + val type: String, + val lastUpdated: Long, + val contents: String ) : Parcelable @Dao interface CacheDao { - @Query("SELECT * FROM frost_cache WHERE id = :id AND type = :type") - fun _select(id: Long, type: String): CacheEntity? + @Query("SELECT * FROM frost_cache WHERE id = :id AND type = :type") + fun _select(id: Long, type: String): CacheEntity? - @Insert(onConflict = OnConflictStrategy.REPLACE) - fun _insertCache(cache: CacheEntity) + @Insert(onConflict = OnConflictStrategy.REPLACE) fun _insertCache(cache: CacheEntity) - @Query("DELETE FROM frost_cache WHERE id = :id AND type = :type") - fun _delete(id: Long, type: String) + @Query("DELETE FROM frost_cache WHERE id = :id AND type = :type") + fun _delete(id: Long, type: String) } -suspend fun CacheDao.select(id: Long, type: String) = dao { - _select(id, type) -} +suspend fun CacheDao.select(id: Long, type: String) = dao { _select(id, type) } -suspend fun CacheDao.delete(id: Long, type: String) = dao { - _delete(id, type) -} +suspend fun CacheDao.delete(id: Long, type: String) = dao { _delete(id, type) } -/** - * Returns true if successful, given that there are constraints to the insertion - */ +/** Returns true if successful, given that there are constraints to the insertion */ suspend fun CacheDao.save(id: Long, type: String, contents: String): Boolean = dao { - try { - _insertCache(CacheEntity(id, type, System.currentTimeMillis(), contents)) - true - } catch (e: Exception) { - L.e(e) { "Cache save failed for $type" } - false - } + try { + _insertCache(CacheEntity(id, type, System.currentTimeMillis(), contents)) + true + } catch (e: Exception) { + L.e(e) { "Cache save failed for $type" } + false + } } diff --git a/app/src/main/kotlin/com/pitchedapps/frost/db/CookiesDb.kt b/app/src/main/kotlin/com/pitchedapps/frost/db/CookiesDb.kt index 8c2e32a76..4e040b2f3 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/db/CookiesDb.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/db/CookiesDb.kt @@ -28,59 +28,57 @@ import androidx.sqlite.db.SupportSQLiteDatabase import com.pitchedapps.frost.prefs.Prefs import kotlinx.android.parcel.Parcelize -/** - * Created by Allan Wang on 2017-05-30. - */ - +/** Created by Allan Wang on 2017-05-30. */ @Entity(tableName = "cookies") @Parcelize data class CookieEntity( - @androidx.room.PrimaryKey - @ColumnInfo(name = "cookie_id") - val id: Long, - val name: String?, - val cookie: String?, - val cookieMessenger: String? = null // Version 2 + @androidx.room.PrimaryKey @ColumnInfo(name = "cookie_id") val id: Long, + val name: String?, + val cookie: String?, + val cookieMessenger: String? = null // Version 2 ) : Parcelable { - override fun toString(): String = "CookieEntity(${hashCode()})" + override fun toString(): String = "CookieEntity(${hashCode()})" - fun toSensitiveString(): String = - "CookieEntity(id=$id, name=$name, cookie=$cookie cookieMessenger=$cookieMessenger)" + fun toSensitiveString(): String = + "CookieEntity(id=$id, name=$name, cookie=$cookie cookieMessenger=$cookieMessenger)" } @Dao interface CookieDao { - @Query("SELECT * FROM cookies") - fun _selectAll(): List + @Query("SELECT * FROM cookies") fun _selectAll(): List - @Query("SELECT * FROM cookies WHERE cookie_id = :id") - fun _selectById(id: Long): CookieEntity? + @Query("SELECT * FROM cookies WHERE cookie_id = :id") fun _selectById(id: Long): CookieEntity? - @Insert(onConflict = OnConflictStrategy.REPLACE) - fun _save(cookie: CookieEntity) + @Insert(onConflict = OnConflictStrategy.REPLACE) fun _save(cookie: CookieEntity) - @Insert(onConflict = OnConflictStrategy.REPLACE) - fun _save(cookies: List) + @Insert(onConflict = OnConflictStrategy.REPLACE) fun _save(cookies: List) - @Query("DELETE FROM cookies WHERE cookie_id = :id") - fun _deleteById(id: Long) + @Query("DELETE FROM cookies WHERE cookie_id = :id") fun _deleteById(id: Long) - @Query("UPDATE cookies SET cookieMessenger = :cookie WHERE cookie_id = :id") - fun _updateMessengerCookie(id: Long, cookie: String?) + @Query("UPDATE cookies SET cookieMessenger = :cookie WHERE cookie_id = :id") + fun _updateMessengerCookie(id: Long, cookie: String?) } suspend fun CookieDao.selectAll() = dao { _selectAll() } -suspend fun CookieDao.selectById(id: Long) = dao { _selectById(id) } -suspend fun CookieDao.save(cookie: CookieEntity) = dao { _save(cookie) } -suspend fun CookieDao.save(cookies: List) = dao { _save(cookies) } -suspend fun CookieDao.deleteById(id: Long) = dao { _deleteById(id) } -suspend fun CookieDao.currentCookie(prefs: Prefs) = selectById(prefs.userId) -suspend fun CookieDao.updateMessengerCookie(id: Long, cookie: String?) = - dao { _updateMessengerCookie(id, cookie) } -val COOKIES_MIGRATION_1_2 = object : Migration(1, 2) { - override fun migrate(database: SupportSQLiteDatabase) { - database.execSQL("ALTER TABLE cookies ADD COLUMN cookieMessenger TEXT") - } +suspend fun CookieDao.selectById(id: Long) = dao { _selectById(id) } + +suspend fun CookieDao.save(cookie: CookieEntity) = dao { _save(cookie) } + +suspend fun CookieDao.save(cookies: List) = dao { _save(cookies) } + +suspend fun CookieDao.deleteById(id: Long) = dao { _deleteById(id) } + +suspend fun CookieDao.currentCookie(prefs: Prefs) = selectById(prefs.userId) + +suspend fun CookieDao.updateMessengerCookie(id: Long, cookie: String?) = dao { + _updateMessengerCookie(id, cookie) } + +val COOKIES_MIGRATION_1_2 = + object : Migration(1, 2) { + override fun migrate(database: SupportSQLiteDatabase) { + database.execSQL("ALTER TABLE cookies ADD COLUMN cookieMessenger TEXT") + } + } diff --git a/app/src/main/kotlin/com/pitchedapps/frost/db/DaoUtils.kt b/app/src/main/kotlin/com/pitchedapps/frost/db/DaoUtils.kt index c31aa9b72..bf71f8f48 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/db/DaoUtils.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/db/DaoUtils.kt @@ -20,9 +20,8 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext /** - * Wraps dao calls to work with coroutines - * Non transactional queries were supposed to be fixed in https://issuetracker.google.com/issues/69474692, - * but it still requires dispatch from a non ui thread. - * This avoids that constraint + * Wraps dao calls to work with coroutines Non transactional queries were supposed to be fixed in + * https://issuetracker.google.com/issues/69474692, but it still requires dispatch from a non ui + * thread. This avoids that constraint */ suspend inline fun dao(crossinline block: () -> T) = withContext(Dispatchers.IO) { block() } diff --git a/app/src/main/kotlin/com/pitchedapps/frost/db/Database.kt b/app/src/main/kotlin/com/pitchedapps/frost/db/Database.kt index ef7636174..de0985b04 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/db/Database.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/db/Database.kt @@ -29,98 +29,100 @@ import dagger.hilt.components.SingletonComponent import javax.inject.Singleton interface FrostPrivateDao { - fun cookieDao(): CookieDao - fun notifDao(): NotificationDao - fun cacheDao(): CacheDao + fun cookieDao(): CookieDao + fun notifDao(): NotificationDao + fun cacheDao(): CacheDao } @Database( - entities = [CookieEntity::class, NotificationEntity::class, CacheEntity::class], - version = 2, - exportSchema = true + entities = [CookieEntity::class, NotificationEntity::class, CacheEntity::class], + version = 2, + exportSchema = true ) abstract class FrostPrivateDatabase : RoomDatabase(), FrostPrivateDao { - companion object { - const val DATABASE_NAME = "frost-priv-db" - } + companion object { + const val DATABASE_NAME = "frost-priv-db" + } } interface FrostPublicDao { - fun genericDao(): GenericDao + fun genericDao(): GenericDao } @Database(entities = [GenericEntity::class], version = 1, exportSchema = true) abstract class FrostPublicDatabase : RoomDatabase(), FrostPublicDao { - companion object { - const val DATABASE_NAME = "frost-db" - } + companion object { + const val DATABASE_NAME = "frost-db" + } } interface FrostDao : FrostPrivateDao, FrostPublicDao { - fun close() + fun close() } -/** - * Composition of all database interfaces - */ +/** Composition of all database interfaces */ class FrostDatabase( - private val privateDb: FrostPrivateDatabase, - private val publicDb: FrostPublicDatabase -) : - FrostDao, - FrostPrivateDao by privateDb, - FrostPublicDao by publicDb { + private val privateDb: FrostPrivateDatabase, + private val publicDb: FrostPublicDatabase +) : FrostDao, FrostPrivateDao by privateDb, FrostPublicDao by publicDb { - override fun close() { - privateDb.close() - publicDb.close() - } - - companion object { - - private fun RoomDatabase.Builder.frostBuild() = - if (BuildConfig.DEBUG) { - fallbackToDestructiveMigration().build() - } else { - build() - } - - fun create(context: Context): FrostDatabase { - val privateDb = Room.databaseBuilder( - context, FrostPrivateDatabase::class.java, - FrostPrivateDatabase.DATABASE_NAME - ).addMigrations(COOKIES_MIGRATION_1_2).frostBuild() - val publicDb = Room.databaseBuilder( - context, FrostPublicDatabase::class.java, - FrostPublicDatabase.DATABASE_NAME - ).frostBuild() - return FrostDatabase(privateDb, publicDb) - } + override fun close() { + privateDb.close() + publicDb.close() + } + + companion object { + + private fun RoomDatabase.Builder.frostBuild() = + if (BuildConfig.DEBUG) { + fallbackToDestructiveMigration().build() + } else { + build() + } + + fun create(context: Context): FrostDatabase { + val privateDb = + Room.databaseBuilder( + context, + FrostPrivateDatabase::class.java, + FrostPrivateDatabase.DATABASE_NAME + ) + .addMigrations(COOKIES_MIGRATION_1_2) + .frostBuild() + val publicDb = + Room.databaseBuilder( + context, + FrostPublicDatabase::class.java, + FrostPublicDatabase.DATABASE_NAME + ) + .frostBuild() + return FrostDatabase(privateDb, publicDb) } + } } @Module @InstallIn(SingletonComponent::class) object DatabaseModule { - @Provides - @Singleton - fun frostDatabase(@ApplicationContext context: Context): FrostDatabase = - FrostDatabase.create(context) + @Provides + @Singleton + fun frostDatabase(@ApplicationContext context: Context): FrostDatabase = + FrostDatabase.create(context) - @Provides - @Singleton - fun cookieDao(frostDatabase: FrostDatabase): CookieDao = frostDatabase.cookieDao() + @Provides + @Singleton + fun cookieDao(frostDatabase: FrostDatabase): CookieDao = frostDatabase.cookieDao() - @Provides - @Singleton - fun cacheDao(frostDatabase: FrostDatabase): CacheDao = frostDatabase.cacheDao() + @Provides + @Singleton + fun cacheDao(frostDatabase: FrostDatabase): CacheDao = frostDatabase.cacheDao() - @Provides - @Singleton - fun notifDao(frostDatabase: FrostDatabase): NotificationDao = frostDatabase.notifDao() + @Provides + @Singleton + fun notifDao(frostDatabase: FrostDatabase): NotificationDao = frostDatabase.notifDao() - @Provides - @Singleton - fun genericDao(frostDatabase: FrostDatabase): GenericDao = frostDatabase.genericDao() + @Provides + @Singleton + fun genericDao(frostDatabase: FrostDatabase): GenericDao = frostDatabase.genericDao() } diff --git a/app/src/main/kotlin/com/pitchedapps/frost/db/GenericDb.kt b/app/src/main/kotlin/com/pitchedapps/frost/db/GenericDb.kt index b7274c338..72bcb5971 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/db/GenericDb.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/db/GenericDb.kt @@ -25,49 +25,35 @@ import androidx.room.Query import com.pitchedapps.frost.facebook.FbItem import com.pitchedapps.frost.facebook.defaultTabs -/** - * Created by Allan Wang on 2017-05-30. - */ +/** Created by Allan Wang on 2017-05-30. */ -/** - * Generic cache to store serialized content - */ +/** Generic cache to store serialized content */ @Entity(tableName = "frost_generic") -data class GenericEntity( - @PrimaryKey - val type: String, - val contents: String -) +data class GenericEntity(@PrimaryKey val type: String, val contents: String) @Dao interface GenericDao { - @Query("SELECT contents FROM frost_generic WHERE type = :type") - fun _select(type: String): String? + @Query("SELECT contents FROM frost_generic WHERE type = :type") fun _select(type: String): String? - @Insert(onConflict = OnConflictStrategy.REPLACE) - fun _save(entity: GenericEntity) + @Insert(onConflict = OnConflictStrategy.REPLACE) fun _save(entity: GenericEntity) - @Query("DELETE FROM frost_generic WHERE type = :type") - fun _delete(type: String) + @Query("DELETE FROM frost_generic WHERE type = :type") fun _delete(type: String) - companion object { - const val TYPE_TABS = "generic_tabs" - } + companion object { + const val TYPE_TABS = "generic_tabs" + } } const val TAB_COUNT = 4 suspend fun GenericDao.saveTabs(tabs: List) = dao { - val content = tabs.joinToString(",") { it.name } - _save(GenericEntity(GenericDao.TYPE_TABS, content)) + val content = tabs.joinToString(",") { it.name } + _save(GenericEntity(GenericDao.TYPE_TABS, content)) } suspend fun GenericDao.getTabs(): List = dao { - val allTabs = FbItem.values.map { it.name to it }.toMap() - _select(GenericDao.TYPE_TABS) - ?.split(",") - ?.mapNotNull { allTabs[it] } - ?.takeIf { it.isNotEmpty() } - ?: defaultTabs() + val allTabs = FbItem.values.map { it.name to it }.toMap() + _select(GenericDao.TYPE_TABS)?.split(",")?.mapNotNull { allTabs[it] }?.takeIf { it.isNotEmpty() } + ?: defaultTabs() } diff --git a/app/src/main/kotlin/com/pitchedapps/frost/db/NotificationDb.kt b/app/src/main/kotlin/com/pitchedapps/frost/db/NotificationDb.kt index f8f16e26e..981100b21 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/db/NotificationDb.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/db/NotificationDb.kt @@ -30,127 +30,120 @@ import com.pitchedapps.frost.services.NotificationContent import com.pitchedapps.frost.utils.L @Entity( - tableName = "notifications", - primaryKeys = ["notif_id", "userId"], - foreignKeys = [ - ForeignKey( - entity = CookieEntity::class, - parentColumns = ["cookie_id"], - childColumns = ["userId"], - onDelete = ForeignKey.CASCADE - ) - ], - indices = [Index("notif_id"), Index("userId")] + tableName = "notifications", + primaryKeys = ["notif_id", "userId"], + foreignKeys = + [ + ForeignKey( + entity = CookieEntity::class, + parentColumns = ["cookie_id"], + childColumns = ["userId"], + onDelete = ForeignKey.CASCADE + )], + indices = [Index("notif_id"), Index("userId")] ) data class NotificationEntity( - @ColumnInfo(name = "notif_id") - val id: Long, - val userId: Long, - val href: String, - val title: String?, - val text: String, - val timestamp: Long, - val profileUrl: String?, - // Type essentially refers to channel - val type: String, - val unread: Boolean + @ColumnInfo(name = "notif_id") val id: Long, + val userId: Long, + val href: String, + val title: String?, + val text: String, + val timestamp: Long, + val profileUrl: String?, + // Type essentially refers to channel + val type: String, + val unread: Boolean ) { - constructor( - type: String, - content: NotificationContent - ) : this( - content.id, - content.data.id, - content.href, - content.title, - content.text, - content.timestamp, - content.profileUrl, - type, - content.unread - ) + constructor( + type: String, + content: NotificationContent + ) : this( + content.id, + content.data.id, + content.href, + content.title, + content.text, + content.timestamp, + content.profileUrl, + type, + content.unread + ) } data class NotificationContentEntity( - @Embedded - val cookie: CookieEntity, - @Embedded - val notif: NotificationEntity + @Embedded val cookie: CookieEntity, + @Embedded val notif: NotificationEntity ) { - fun toNotifContent() = NotificationContent( - data = cookie, - id = notif.id, - href = notif.href, - title = notif.title, - text = notif.text, - timestamp = notif.timestamp, - profileUrl = notif.profileUrl, - unread = notif.unread + fun toNotifContent() = + NotificationContent( + data = cookie, + id = notif.id, + href = notif.href, + title = notif.title, + text = notif.text, + timestamp = notif.timestamp, + profileUrl = notif.profileUrl, + unread = notif.unread ) } @Dao interface NotificationDao { - /** - * Note that notifications are guaranteed to be ordered by descending timestamp - */ - @Transaction - @Query("SELECT * FROM cookies INNER JOIN notifications ON cookie_id = userId WHERE userId = :userId AND type = :type ORDER BY timestamp DESC") - fun _selectNotifications(userId: Long, type: String): List + /** Note that notifications are guaranteed to be ordered by descending timestamp */ + @Transaction + @Query( + "SELECT * FROM cookies INNER JOIN notifications ON cookie_id = userId WHERE userId = :userId AND type = :type ORDER BY timestamp DESC" + ) + fun _selectNotifications(userId: Long, type: String): List - @Query("SELECT timestamp FROM notifications WHERE userId = :userId AND type = :type ORDER BY timestamp DESC LIMIT 1") - fun _selectEpoch(userId: Long, type: String): Long? + @Query( + "SELECT timestamp FROM notifications WHERE userId = :userId AND type = :type ORDER BY timestamp DESC LIMIT 1" + ) + fun _selectEpoch(userId: Long, type: String): Long? - @Insert(onConflict = OnConflictStrategy.REPLACE) - fun _insertNotifications(notifs: List) + @Insert(onConflict = OnConflictStrategy.REPLACE) + fun _insertNotifications(notifs: List) - @Query("DELETE FROM notifications WHERE userId = :userId AND type = :type") - fun _deleteNotifications(userId: Long, type: String) + @Query("DELETE FROM notifications WHERE userId = :userId AND type = :type") + fun _deleteNotifications(userId: Long, type: String) - @Query("DELETE FROM notifications") - fun _deleteAll() + @Query("DELETE FROM notifications") fun _deleteAll() - /** - * It is assumed that the notification batch comes from the same user - */ - @Transaction - fun _saveNotifications(type: String, notifs: List) { - val userId = notifs.firstOrNull()?.data?.id ?: return - val entities = notifs.map { NotificationEntity(type, it) } - _deleteNotifications(userId, type) - _insertNotifications(entities) - } + /** It is assumed that the notification batch comes from the same user */ + @Transaction + fun _saveNotifications(type: String, notifs: List) { + val userId = notifs.firstOrNull()?.data?.id ?: return + val entities = notifs.map { NotificationEntity(type, it) } + _deleteNotifications(userId, type) + _insertNotifications(entities) + } } suspend fun NotificationDao.deleteAll() = dao { _deleteAll() } fun NotificationDao.selectNotificationsSync(userId: Long, type: String): List = - _selectNotifications(userId, type).map { it.toNotifContent() } + _selectNotifications(userId, type).map { it.toNotifContent() } suspend fun NotificationDao.selectNotifications( - userId: Long, - type: String -): List = dao { - selectNotificationsSync(userId, type) -} + userId: Long, + type: String +): List = dao { selectNotificationsSync(userId, type) } -/** - * Returns true if successful, given that there are constraints to the insertion - */ +/** Returns true if successful, given that there are constraints to the insertion */ suspend fun NotificationDao.saveNotifications( - type: String, - notifs: List + type: String, + notifs: List ): Boolean = dao { - try { - _saveNotifications(type, notifs) - true - } catch (e: Exception) { - L.e(e) { "Notif save failed for $type" } - false - } + try { + _saveNotifications(type, notifs) + true + } catch (e: Exception) { + L.e(e) { "Notif save failed for $type" } + false + } } suspend fun NotificationDao.latestEpoch(userId: Long, type: String): Long = dao { - _selectEpoch(userId, type) ?: -1L + _selectEpoch(userId, type) ?: -1L } diff --git a/app/src/main/kotlin/com/pitchedapps/frost/debugger/OfflineWebsite.kt b/app/src/main/kotlin/com/pitchedapps/frost/debugger/OfflineWebsite.kt index 75a13295c..3c50727db 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/debugger/OfflineWebsite.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/debugger/OfflineWebsite.kt @@ -26,6 +26,12 @@ import com.pitchedapps.frost.utils.createFreshDir import com.pitchedapps.frost.utils.createFreshFile import com.pitchedapps.frost.utils.frostJsoup import com.pitchedapps.frost.utils.unescapeHtml +import java.io.File +import java.io.FileOutputStream +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.atomic.AtomicInteger +import java.util.zip.ZipEntry +import java.util.zip.ZipOutputStream import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.withContext @@ -37,12 +43,6 @@ import org.jsoup.Jsoup import org.jsoup.nodes.Document import org.jsoup.nodes.Element import org.jsoup.nodes.Entities -import java.io.File -import java.io.FileOutputStream -import java.util.concurrent.ConcurrentHashMap -import java.util.concurrent.atomic.AtomicInteger -import java.util.zip.ZipEntry -import java.util.zip.ZipOutputStream /** * Created by Allan Wang on 04/01/18. @@ -52,281 +52,271 @@ import java.util.zip.ZipOutputStream * Inspired by Save for Offline */ class OfflineWebsite( - private val url: String, - private val cookie: String = "", - baseUrl: String? = null, - private val html: String? = null, - /** - * Directory that holds all the files - */ - val baseDir: File, - private val userAgent: String = USER_AGENT + private val url: String, + private val cookie: String = "", + baseUrl: String? = null, + private val html: String? = null, + /** Directory that holds all the files */ + val baseDir: File, + private val userAgent: String = USER_AGENT ) { - /** - * Supplied url without the queries - */ - private val baseUrl: String = baseUrl ?: run { + /** Supplied url without the queries */ + private val baseUrl: String = + baseUrl + ?: run { val url: HttpUrl = url.toHttpUrlOrNull() ?: throw IllegalArgumentException("Malformed url") return@run "${url.scheme}://${url.host}" + } + + private val mainFile = File(baseDir, "index.html") + private val assetDir = File(baseDir, "assets") + + private val urlMapper = ConcurrentHashMap() + private val atomicInt = AtomicInteger() + + private val L = KauLoggerExtension("Offline", com.pitchedapps.frost.utils.L) + + init { + if (!this.baseUrl.startsWith("http")) + throw IllegalArgumentException("Base Url must start with http") + } + + private val fileQueue = mutableSetOf() + + private val cssQueue = mutableSetOf() + + private fun request(url: String) = + Request.Builder().header("Cookie", cookie).header("User-Agent", userAgent).url(url).get().call() + + /** + * Caller to bind callbacks and start the load Callback is guaranteed to be called unless the load + * is cancelled + */ + suspend fun load(progress: (Int) -> Unit = {}): Boolean = + withContext(Dispatchers.IO) { + reset() + + L.v { "Saving $url to ${baseDir.absolutePath}" } + + if (!baseDir.isDirectory && !baseDir.mkdirs()) { + L.e { "Could not make directory" } + return@withContext false + } + + if (!mainFile.createNewFile()) { + L.e { "Could not create ${mainFile.absolutePath}" } + return@withContext false + } + + if (!assetDir.createFreshDir()) { + L.e { "Could not create ${assetDir.absolutePath}" } + return@withContext false + } + + progress(10) + + yield() + + val doc: Document + if (html == null || html.length < 100) { + doc = frostJsoup(cookie, url) + } else { + doc = Jsoup.parse("${html.unescapeHtml()}") + L.d { "Building data from supplied content of size ${html.length}" } + } + doc.setBaseUri(baseUrl) + doc.outputSettings().escapeMode(Entities.EscapeMode.extended) + if (doc.childNodeSize() == 0) { + L.e { "No content found" } + return@withContext false + } + + yield() + + progress(35) + + doc.collect("link[href][rel=stylesheet]", "href", cssQueue) + doc.collect("link[href]:not([rel=stylesheet])", "href", fileQueue) + doc.collect("img[src]", "src", fileQueue) + doc.collect("img[data-canonical-src]", "data-canonical-src", fileQueue) + doc.collect("script[src]", "src", fileQueue) + + // make links absolute + doc.select("a[href]").forEach { + val absLink = it.attr("abs:href") + it.attr("href", absLink) + } + + yield() + + mainFile.writeText(doc.html()) + + progress(50) + + fun partialProgress(from: Int, to: Int, steps: Int): (Int) -> Unit { + if (steps == 0) return { progress(to) } + val section = (to - from) / steps + return { progress(from + it * section) } + } + + val cssProgress = partialProgress(50, 70, cssQueue.size) + + cssQueue.clean().forEachIndexed { index, url -> + yield() + cssProgress(index) + val newUrls = downloadCss(url) + fileQueue.addAll(newUrls) + } + + progress(70) + + val fileProgress = partialProgress(70, 100, fileQueue.size) + + fileQueue.clean().forEachIndexed { index, url -> + yield() + fileProgress(index) + if (!downloadFile(url)) return@withContext false + } + + yield() + progress(100) + return@withContext true } - private val mainFile = File(baseDir, "index.html") - private val assetDir = File(baseDir, "assets") + fun zip(name: String): Boolean { + try { + val zip = File(baseDir, "$name.zip") + if (!zip.createFreshFile()) { + L.e { "Failed to create zip at ${zip.absolutePath}" } + return false + } - private val urlMapper = ConcurrentHashMap() - private val atomicInt = AtomicInteger() + ZipOutputStream(FileOutputStream(zip)).use { out -> + fun File.zip(name: String = this.name) { + if (!isFile) return + inputStream().use { file -> + out.putNextEntry(ZipEntry(name)) + file.copyTo(out) + } + out.closeEntry() + delete() + } + baseDir.listFiles { file -> file != zip }?.forEach { it.zip() } + assetDir.listFiles()?.forEach { it.zip("assets/${it.name}") } - private val L = KauLoggerExtension("Offline", com.pitchedapps.frost.utils.L) - - init { - if (!this.baseUrl.startsWith("http")) - throw IllegalArgumentException("Base Url must start with http") + assetDir.delete() + } + return true + } catch (e: Exception) { + L.e { "Zip failed: ${e.message}" } + return false } + } - private val fileQueue = mutableSetOf() - - private val cssQueue = mutableSetOf() - - private fun request(url: String) = Request.Builder() - .header("Cookie", cookie) - .header("User-Agent", userAgent) - .url(url) - .get() - .call() - - /** - * Caller to bind callbacks and start the load - * Callback is guaranteed to be called unless the load is cancelled - */ - suspend fun load(progress: (Int) -> Unit = {}): Boolean = withContext(Dispatchers.IO) { - reset() - - L.v { "Saving $url to ${baseDir.absolutePath}" } - - if (!baseDir.isDirectory && !baseDir.mkdirs()) { - L.e { "Could not make directory" } - return@withContext false - } - - if (!mainFile.createNewFile()) { - L.e { "Could not create ${mainFile.absolutePath}" } - return@withContext false - } - - if (!assetDir.createFreshDir()) { - L.e { "Could not create ${assetDir.absolutePath}" } - return@withContext false - } - - progress(10) - - yield() - - val doc: Document - if (html == null || html.length < 100) { - doc = frostJsoup(cookie, url) - } else { - doc = Jsoup.parse("${html.unescapeHtml()}") - L.d { "Building data from supplied content of size ${html.length}" } - } - doc.setBaseUri(baseUrl) - doc.outputSettings().escapeMode(Entities.EscapeMode.extended) - if (doc.childNodeSize() == 0) { - L.e { "No content found" } - return@withContext false - } - - yield() - - progress(35) - - doc.collect("link[href][rel=stylesheet]", "href", cssQueue) - doc.collect("link[href]:not([rel=stylesheet])", "href", fileQueue) - doc.collect("img[src]", "src", fileQueue) - doc.collect("img[data-canonical-src]", "data-canonical-src", fileQueue) - doc.collect("script[src]", "src", fileQueue) - - // make links absolute - doc.select("a[href]").forEach { - val absLink = it.attr("abs:href") - it.attr("href", absLink) - } - - yield() - - mainFile.writeText(doc.html()) - - progress(50) - - fun partialProgress(from: Int, to: Int, steps: Int): (Int) -> Unit { - if (steps == 0) return { progress(to) } - val section = (to - from) / steps - return { progress(from + it * section) } - } - - val cssProgress = partialProgress(50, 70, cssQueue.size) - - cssQueue.clean().forEachIndexed { index, url -> - yield() - cssProgress(index) - val newUrls = downloadCss(url) - fileQueue.addAll(newUrls) - } - - progress(70) - - val fileProgress = partialProgress(70, 100, fileQueue.size) - - fileQueue.clean().forEachIndexed { index, url -> - yield() - fileProgress(index) - if (!downloadFile(url)) - return@withContext false - } - - yield() + suspend fun loadAndZip(name: String, progress: (Int) -> Unit = {}): Boolean = + withContext(Dispatchers.IO) { + coroutineScope { + val success = load { progress((it * 0.85f).toInt()) } + if (!success) return@coroutineScope false + val result = zip(name) progress(100) - return@withContext true + return@coroutineScope result + } } - fun zip(name: String): Boolean { - try { - val zip = File(baseDir, "$name.zip") - if (!zip.createFreshFile()) { - L.e { "Failed to create zip at ${zip.absolutePath}" } - return false - } - - ZipOutputStream(FileOutputStream(zip)).use { out -> - - fun File.zip(name: String = this.name) { - if (!isFile) return - inputStream().use { file -> - out.putNextEntry(ZipEntry(name)) - file.copyTo(out) - } - out.closeEntry() - delete() - } - baseDir.listFiles { file -> file != zip } - ?.forEach { it.zip() } - assetDir.listFiles() - ?.forEach { it.zip("assets/${it.name}") } - - assetDir.delete() - } - return true - } catch (e: Exception) { - L.e { "Zip failed: ${e.message}" } - return false - } + private fun downloadFile(url: String): Boolean { + return try { + val file = File(assetDir, fileName(url)) + file.createNewFile() + val stream = + request(url).execute().body?.byteStream() + ?: throw IllegalArgumentException("Response body not found for $url") + file.copyFromInputStream(stream) + true + } catch (e: Exception) { + L.e(e) { "Download file failed" } + false } + } - suspend fun loadAndZip(name: String, progress: (Int) -> Unit = {}): Boolean = - withContext(Dispatchers.IO) { - coroutineScope { - val success = load { progress((it * 0.85f).toInt()) } - if (!success) return@coroutineScope false - val result = zip(name) - progress(100) - return@coroutineScope result - } - } + private fun downloadCss(url: String): Set { + return try { + val file = File(assetDir, fileName(url)) + file.createNewFile() - private fun downloadFile(url: String): Boolean { - return try { - val file = File(assetDir, fileName(url)) - file.createNewFile() - val stream = request(url).execute().body?.byteStream() - ?: throw IllegalArgumentException("Response body not found for $url") - file.copyFromInputStream(stream) - true - } catch (e: Exception) { - L.e(e) { "Download file failed" } - false - } + var content = + request(url).execute().body?.string() + ?: throw IllegalArgumentException("Response body not found for $url") + val links = FB_CSS_URL_MATCHER.findAll(content).mapNotNull { it[1] } + val absLinks = + links + .mapNotNull { + val newUrl = + when { + it.startsWith("http") -> it + it.startsWith("/") -> "$baseUrl$it" + else -> return@mapNotNull null + } + // css files are already in the asset folder, + // so the url does not point to another subfolder + content = content.replace(it, fileName(newUrl)) + newUrl + } + .toSet() + + file.writeText(content) + absLinks + } catch (e: Exception) { + L.e(e) { "Download css failed" } + emptySet() } + } - private fun downloadCss(url: String): Set { - return try { - val file = File(assetDir, fileName(url)) - file.createNewFile() - - var content = request(url).execute().body?.string() - ?: throw IllegalArgumentException("Response body not found for $url") - val links = FB_CSS_URL_MATCHER.findAll(content).mapNotNull { it[1] } - val absLinks = links.mapNotNull { - val newUrl = when { - it.startsWith("http") -> it - it.startsWith("/") -> "$baseUrl$it" - else -> return@mapNotNull null - } - // css files are already in the asset folder, - // so the url does not point to another subfolder - content = content.replace(it, fileName(newUrl)) - newUrl - }.toSet() - - file.writeText(content) - absLinks - } catch (e: Exception) { - L.e(e) { "Download css failed" } - emptySet() - } + private fun Element.collect(query: String, key: String, collector: MutableSet) { + val data = select(query) + L.v { "Found ${data.size} elements with $query" } + data.forEach { + val absLink = it.attr("abs:$key") + if (!absLink.isValid) return@forEach + collector.add(absLink) + it.attr(key, "assets/${fileName(absLink)}") } + } - private fun Element.collect(query: String, key: String, collector: MutableSet) { - val data = select(query) - L.v { "Found ${data.size} elements with $query" } - data.forEach { - val absLink = it.attr("abs:$key") - if (!absLink.isValid) return@forEach - collector.add(absLink) - it.attr(key, "assets/${fileName(absLink)}") - } - } + private inline val String.isValid + get() = startsWith("http") - private inline val String.isValid - get() = startsWith("http") + /** Fetch the previously discovered filename or create a new one This is thread-safe */ + private fun fileName(url: String): String { + val mapped = urlMapper[url] + if (mapped != null) return mapped + + val candidate = url.substringBefore("?").trim('/').substringAfterLast("/").shorten() + + val index = atomicInt.getAndIncrement() + + var newUrl = "a${index}_$candidate" /** - * Fetch the previously discovered filename - * or create a new one - * This is thread-safe + * This is primarily for zipping up and sending via emails As .js files typically aren't + * allowed, we'll simply make everything txt files */ - private fun fileName(url: String): String { - val mapped = urlMapper[url] - if (mapped != null) return mapped + if (newUrl.endsWith(".js")) newUrl = "$newUrl.txt" - val candidate = url.substringBefore("?").trim('/') - .substringAfterLast("/").shorten() + urlMapper[url] = newUrl + return newUrl + } - val index = atomicInt.getAndIncrement() + private fun String.shorten() = if (length <= 10) this else substring(length - 10) - var newUrl = "a${index}_$candidate" + private fun Set.clean(): List = + filter(String::isNotBlank).filter { it.startsWith("http") } - /** - * This is primarily for zipping up and sending via emails - * As .js files typically aren't allowed, we'll simply make everything txt files - */ - if (newUrl.endsWith(".js")) - newUrl = "$newUrl.txt" - - urlMapper[url] = newUrl - return newUrl - } - - private fun String.shorten() = - if (length <= 10) this else substring(length - 10) - - private fun Set.clean(): List = - filter(String::isNotBlank).filter { it.startsWith("http") } - - private fun reset() { - urlMapper.clear() - atomicInt.set(0) - fileQueue.clear() - cssQueue.clear() - } + private fun reset() { + urlMapper.clear() + atomicInt.set(0) + fileQueue.clear() + cssQueue.clear() + } } diff --git a/app/src/main/kotlin/com/pitchedapps/frost/enums/FeedSort.kt b/app/src/main/kotlin/com/pitchedapps/frost/enums/FeedSort.kt index 1fbba8121..fffd2706e 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/enums/FeedSort.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/enums/FeedSort.kt @@ -20,16 +20,14 @@ import androidx.annotation.StringRes import com.pitchedapps.frost.R import com.pitchedapps.frost.facebook.FbItem -/** - * Created by Allan Wang on 2017-06-23. - */ +/** Created by Allan Wang on 2017-06-23. */ enum class FeedSort(@StringRes val textRes: Int, val item: FbItem) { - DEFAULT(R.string.kau_default, FbItem.FEED), - MOST_RECENT(R.string.most_recent, FbItem.FEED_MOST_RECENT), - TOP(R.string.top_stories, FbItem.FEED_TOP_STORIES); + DEFAULT(R.string.kau_default, FbItem.FEED), + MOST_RECENT(R.string.most_recent, FbItem.FEED_MOST_RECENT), + TOP(R.string.top_stories, FbItem.FEED_TOP_STORIES); - companion object { - val values = values() // save one instance - operator fun invoke(index: Int) = values[index] - } + companion object { + val values = values() // save one instance + operator fun invoke(index: Int) = values[index] + } } diff --git a/app/src/main/kotlin/com/pitchedapps/frost/enums/MainActivityLayout.kt b/app/src/main/kotlin/com/pitchedapps/frost/enums/MainActivityLayout.kt index 4a274b9b4..c1d5d2289 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/enums/MainActivityLayout.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/enums/MainActivityLayout.kt @@ -19,29 +19,17 @@ package com.pitchedapps.frost.enums import com.pitchedapps.frost.R import com.pitchedapps.frost.injectors.ThemeProvider -/** - * Created by Allan Wang on 2017-08-19. - */ +/** Created by Allan Wang on 2017-08-19. */ enum class MainActivityLayout( - val titleRes: Int, - val backgroundColor: (ThemeProvider) -> Int, - val iconColor: (ThemeProvider) -> Int + val titleRes: Int, + val backgroundColor: (ThemeProvider) -> Int, + val iconColor: (ThemeProvider) -> Int ) { + TOP_BAR(R.string.top_bar, { it.headerColor }, { it.iconColor }), + BOTTOM_BAR(R.string.bottom_bar, { it.bgColor }, { it.textColor }); - TOP_BAR( - R.string.top_bar, - { it.headerColor }, - { it.iconColor } - ), - - BOTTOM_BAR( - R.string.bottom_bar, - { it.bgColor }, - { it.textColor } - ); - - companion object { - val values = values() // save one instance - operator fun invoke(index: Int) = values.getOrElse(index) { TOP_BAR } - } + companion object { + val values = values() // save one instance + operator fun invoke(index: Int) = values.getOrElse(index) { TOP_BAR } + } } diff --git a/app/src/main/kotlin/com/pitchedapps/frost/enums/OverlayContext.kt b/app/src/main/kotlin/com/pitchedapps/frost/enums/OverlayContext.kt index d529db125..af3999175 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/enums/OverlayContext.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/enums/OverlayContext.kt @@ -30,51 +30,45 @@ import com.pitchedapps.frost.views.FrostWebView /** * Created by Allan Wang on 2017-09-16. * - * Options for [WebOverlayActivityBase] to give more info as to what kind of - * overlay is present. + * Options for [WebOverlayActivityBase] to give more info as to what kind of overlay is present. * * For now, this is able to add new menu options upon first load */ enum class OverlayContext(private val menuItem: FrostMenuItem?) : EnumBundle { + NOTIFICATION(FrostMenuItem(R.id.action_notification, FbItem.NOTIFICATIONS)), + MESSAGE(FrostMenuItem(R.id.action_messages, FbItem.MESSAGES)); - NOTIFICATION(FrostMenuItem(R.id.action_notification, FbItem.NOTIFICATIONS)), - MESSAGE(FrostMenuItem(R.id.action_messages, FbItem.MESSAGES)); + /** Inject the [menuItem] in the order that they are given at the front of the menu */ + fun onMenuCreate(context: Context, menu: Menu) { + menuItem?.addToMenu(context, menu, 0) + } + + override val bundleContract: EnumBundleCompanion + get() = Companion + + companion object : EnumCompanion("frost_arg_overlay_context", values()) { /** - * Inject the [menuItem] in the order that they are given at the front of the menu + * Execute selection call for an item by id Returns [true] if selection was consumed, [false] + * otherwise */ - fun onMenuCreate(context: Context, menu: Menu) { - menuItem?.addToMenu(context, menu, 0) - } - - override val bundleContract: EnumBundleCompanion - get() = Companion - - companion object : EnumCompanion("frost_arg_overlay_context", values()) { - - /** - * Execute selection call for an item by id - * Returns [true] if selection was consumed, [false] otherwise - */ - fun onOptionsItemSelected(web: FrostWebView, id: Int): Boolean { - val item = values.firstOrNull { id == it.menuItem?.id }?.menuItem ?: return false - web.loadUrl(item.fbItem.url, true) - return true - } + fun onOptionsItemSelected(web: FrostWebView, id: Int): Boolean { + val item = values.firstOrNull { id == it.menuItem?.id }?.menuItem ?: return false + web.loadUrl(item.fbItem.url, true) + return true } + } } -/** - * Frame for an injectable menu item - */ +/** Frame for an injectable menu item */ class FrostMenuItem( - val id: Int, - val fbItem: FbItem, - val showAsAction: Int = MenuItem.SHOW_AS_ACTION_IF_ROOM + val id: Int, + val fbItem: FbItem, + val showAsAction: Int = MenuItem.SHOW_AS_ACTION_IF_ROOM ) { - fun addToMenu(context: Context, menu: Menu, index: Int) { - val item = menu.add(Menu.NONE, id, index, fbItem.titleId) - item.icon = fbItem.icon.toDrawable(context, 18) - item.setShowAsAction(showAsAction) - } + fun addToMenu(context: Context, menu: Menu, index: Int) { + val item = menu.add(Menu.NONE, id, index, fbItem.titleId) + item.icon = fbItem.icon.toDrawable(context, 18) + item.setShowAsAction(showAsAction) + } } diff --git a/app/src/main/kotlin/com/pitchedapps/frost/enums/Theme.kt b/app/src/main/kotlin/com/pitchedapps/frost/enums/Theme.kt index fc6f6f9dd..9299e8f54 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/enums/Theme.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/enums/Theme.kt @@ -23,95 +23,85 @@ import com.pitchedapps.frost.R import com.pitchedapps.frost.prefs.sections.ThemePrefs import java.util.Locale -/** - * Created by Allan Wang on 2017-06-14. - */ +/** Created by Allan Wang on 2017-06-14. */ const val FACEBOOK_BLUE = 0xff3b5998.toInt() const val BLUE_LIGHT = 0xff5d86dd.toInt() enum class Theme( - @StringRes val textRes: Int, - file: String?, - val textColorGetter: (ThemePrefs) -> Int, - val accentColorGetter: (ThemePrefs) -> Int, - val backgroundColorGetter: (ThemePrefs) -> Int, - val headerColorGetter: (ThemePrefs) -> Int, - val iconColorGetter: (ThemePrefs) -> Int + @StringRes val textRes: Int, + file: String?, + val textColorGetter: (ThemePrefs) -> Int, + val accentColorGetter: (ThemePrefs) -> Int, + val backgroundColorGetter: (ThemePrefs) -> Int, + val headerColorGetter: (ThemePrefs) -> Int, + val iconColorGetter: (ThemePrefs) -> Int ) { + DEFAULT( + R.string.kau_default, + "default", + { 0xde000000.toInt() }, + { FACEBOOK_BLUE }, + { 0xfffafafa.toInt() }, + { FACEBOOK_BLUE }, + { Color.WHITE } + ), + LIGHT( + R.string.kau_light, + "material_light", + { 0xde000000.toInt() }, + { FACEBOOK_BLUE }, + { 0xfffafafa.toInt() }, + { FACEBOOK_BLUE }, + { Color.WHITE } + ), + DARK( + R.string.kau_dark, + "material_dark", + { Color.WHITE }, + { BLUE_LIGHT }, + { 0xff303030.toInt() }, + { 0xff2e4b86.toInt() }, + { Color.WHITE } + ), + AMOLED( + R.string.kau_amoled, + "material_amoled", + { Color.WHITE }, + { BLUE_LIGHT }, + { Color.BLACK }, + { Color.BLACK }, + { Color.WHITE } + ), + GLASS( + R.string.kau_glass, + "material_glass", + { Color.WHITE }, + { BLUE_LIGHT }, + { 0x80000000.toInt() }, + { 0xb3000000.toInt() }, + { Color.WHITE } + ), + CUSTOM( + R.string.kau_custom, + "custom", + { it.customTextColor }, + { it.customAccentColor }, + { it.customBackgroundColor }, + { it.customHeaderColor }, + { it.customIconColor } + ); - DEFAULT( - R.string.kau_default, - "default", - { 0xde000000.toInt() }, - { FACEBOOK_BLUE }, - { 0xfffafafa.toInt() }, - { FACEBOOK_BLUE }, - { Color.WHITE } - ), + @VisibleForTesting internal val file = file?.let { "$it.css" } - LIGHT( - R.string.kau_light, - "material_light", - { 0xde000000.toInt() }, - { FACEBOOK_BLUE }, - { 0xfffafafa.toInt() }, - { FACEBOOK_BLUE }, - { Color.WHITE } - ), - - DARK( - R.string.kau_dark, - "material_dark", - { Color.WHITE }, - { BLUE_LIGHT }, - { 0xff303030.toInt() }, - { 0xff2e4b86.toInt() }, - { Color.WHITE } - ), - - AMOLED( - R.string.kau_amoled, - "material_amoled", - { Color.WHITE }, - { BLUE_LIGHT }, - { Color.BLACK }, - { Color.BLACK }, - { Color.WHITE } - ), - - GLASS( - R.string.kau_glass, - "material_glass", - { Color.WHITE }, - { BLUE_LIGHT }, - { 0x80000000.toInt() }, - { 0xb3000000.toInt() }, - { Color.WHITE } - ), - - CUSTOM( - R.string.kau_custom, - "custom", - { it.customTextColor }, - { it.customAccentColor }, - { it.customBackgroundColor }, - { it.customHeaderColor }, - { it.customIconColor } - ); - - @VisibleForTesting - internal val file = file?.let { "$it.css" } - - companion object { - val values = values() // save one instance - operator fun invoke(index: Int) = values[index] - } + companion object { + val values = values() // save one instance + operator fun invoke(index: Int) = values[index] + } } enum class ThemeCategory { - FACEBOOK, MESSENGER - ; + FACEBOOK, + MESSENGER; - @VisibleForTesting - internal val folder = name.toLowerCase(Locale.CANADA) + @VisibleForTesting internal val folder = name.toLowerCase(Locale.CANADA) } diff --git a/app/src/main/kotlin/com/pitchedapps/frost/facebook/FbConst.kt b/app/src/main/kotlin/com/pitchedapps/frost/facebook/FbConst.kt index b0846864e..e01fe8a9e 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/facebook/FbConst.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/facebook/FbConst.kt @@ -16,10 +16,7 @@ */ package com.pitchedapps.frost.facebook -/** - * Created by Allan Wang on 2017-06-01. - */ - +/** Created by Allan Wang on 2017-06-01. */ const val FACEBOOK_COM = "facebook.com" const val MESSENGER_COM = "messenger.com" const val FBCDN_NET = "fbcdn.net" @@ -31,7 +28,9 @@ const val FACEBOOK_BASE_COM = "m.$FACEBOOK_COM" const val FB_URL_BASE = "https://$FACEBOOK_BASE_COM/" const val FACEBOOK_MBASIC_COM = "mbasic.$FACEBOOK_COM" const val FB_URL_MBASIC_BASE = "https://$FACEBOOK_MBASIC_COM/" + fun profilePictureUrl(id: Long) = "https://graph.facebook.com/$id/picture?type=large" + const val FB_LOGIN_URL = "${FB_URL_BASE}login" const val FB_HOME_URL = "${FB_URL_BASE}home.php" const val MESSENGER_THREAD_PREFIX = "$HTTPS_MESSENGER_COM/t/" @@ -45,23 +44,19 @@ const val MESSENGER_THREAD_PREFIX = "$HTTPS_MESSENGER_COM/t/" // Default user agent const val USER_AGENT_MOBILE_CONST = - "Mozilla/5.0 (Linux; Android 8.0.0; ONEPLUS A3000) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/73.0.3683.90 Mobile Safari/537.36" + "Mozilla/5.0 (Linux; Android 8.0.0; ONEPLUS A3000) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/73.0.3683.90 Mobile Safari/537.36" // Desktop agent, for pages like messages const val USER_AGENT_DESKTOP_CONST = - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/73.0.3683.90 Safari/537.36" + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/73.0.3683.90 Safari/537.36" const val USER_AGENT = USER_AGENT_DESKTOP_CONST -/** - * Animation transition delay, just to ensure that the styles - * have properly set in - */ +/** Animation transition delay, just to ensure that the styles have properly set in */ const val WEB_LOAD_DELAY = 50L /** - * Additional delay for transition when called from commit. - * Note that transitions are also called from onFinish, so this value - * will never make a load slower than it is + * Additional delay for transition when called from commit. Note that transitions are also called + * from onFinish, so this value will never make a load slower than it is */ const val WEB_COMMIT_LOAD_DELAY = 200L diff --git a/app/src/main/kotlin/com/pitchedapps/frost/facebook/FbCookie.kt b/app/src/main/kotlin/com/pitchedapps/frost/facebook/FbCookie.kt index ab041adcd..7f78442c3 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/facebook/FbCookie.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/facebook/FbCookie.kt @@ -28,149 +28,129 @@ import com.pitchedapps.frost.prefs.Prefs import com.pitchedapps.frost.utils.L import com.pitchedapps.frost.utils.cookies import com.pitchedapps.frost.utils.launchLogin +import javax.inject.Inject +import kotlin.coroutines.resume +import kotlin.coroutines.suspendCoroutine import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.withContext -import javax.inject.Inject -import kotlin.coroutines.resume -import kotlin.coroutines.suspendCoroutine /** * Created by Allan Wang on 2017-05-30. * * The following component manages all cookie transfers. */ -class FbCookie @Inject internal constructor( - private val prefs: Prefs, - private val cookieDao: CookieDao -) { +class FbCookie +@Inject +internal constructor(private val prefs: Prefs, private val cookieDao: CookieDao) { - companion object { - /** - * Domain information. Dot prefix still matters for Android browsers. - */ - private const val FB_COOKIE_DOMAIN = ".$FACEBOOK_COM" - private const val MESSENGER_COOKIE_DOMAIN = ".$MESSENGER_COM" + companion object { + /** Domain information. Dot prefix still matters for Android browsers. */ + private const val FB_COOKIE_DOMAIN = ".$FACEBOOK_COM" + private const val MESSENGER_COOKIE_DOMAIN = ".$MESSENGER_COM" + } + + /** Retrieves the facebook cookie if it exists Note that this is a synchronized call */ + val webCookie: String? + get() = CookieManager.getInstance().getCookie(HTTPS_FACEBOOK_COM) + + val messengerCookie: String? + get() = CookieManager.getInstance().getCookie(HTTPS_MESSENGER_COM) + + private suspend fun CookieManager.suspendSetWebCookie(domain: String, cookie: String?): Boolean { + cookie ?: return true + return withContext(NonCancellable) { + // Save all cookies regardless of result, then check if all succeeded + val result = + cookie.split(";").map { async { setSingleWebCookie(domain, it) } }.awaitAll().all { it } + L.d { "Cookies set" } + L._d { "Set $cookie\n\tResult $webCookie" } + result + } + } + + private suspend fun CookieManager.setSingleWebCookie(domain: String, cookie: String): Boolean = + suspendCoroutine { cont -> + setCookie(domain, cookie.trim()) { cont.resume(it) } } - /** - * Retrieves the facebook cookie if it exists - * Note that this is a synchronized call - */ - val webCookie: String? - get() = CookieManager.getInstance().getCookie(HTTPS_FACEBOOK_COM) + private suspend fun CookieManager.removeAllCookies(): Boolean = suspendCoroutine { cont -> + removeAllCookies { cont.resume(it) } + } - val messengerCookie: String? - get() = CookieManager.getInstance().getCookie(HTTPS_MESSENGER_COM) + suspend fun save(id: Long) { + L.d { "New cookie found" } + prefs.userId = id + CookieManager.getInstance().flush() + val cookie = CookieEntity(prefs.userId, null, webCookie) + cookieDao.save(cookie) + } - private suspend fun CookieManager.suspendSetWebCookie( - domain: String, - cookie: String? - ): Boolean { - cookie ?: return true - return withContext(NonCancellable) { - // Save all cookies regardless of result, then check if all succeeded - val result = cookie.split(";") - .map { async { setSingleWebCookie(domain, it) } } - .awaitAll().all { it } - L.d { "Cookies set" } - L._d { "Set $cookie\n\tResult $webCookie" } - result - } + suspend fun reset() { + prefs.userId = -1L + withContext(Dispatchers.Main + NonCancellable) { + with(CookieManager.getInstance()) { + removeAllCookies() + flush() + } } + } - private suspend fun CookieManager.setSingleWebCookie(domain: String, cookie: String): Boolean = - suspendCoroutine { cont -> - setCookie(domain, cookie.trim()) { - cont.resume(it) - } - } + suspend fun switchUser(id: Long) { + val cookie = cookieDao.selectById(id) ?: return L.e { "No cookie for id" } + switchUser(cookie) + } - private suspend fun CookieManager.removeAllCookies(): Boolean = suspendCoroutine { cont -> - removeAllCookies { - cont.resume(it) - } + suspend fun switchUser(cookie: CookieEntity?) { + if (cookie?.cookie == null) { + L.d { "Switching User; null cookie" } + return } - - suspend fun save(id: Long) { - L.d { "New cookie found" } - prefs.userId = id - CookieManager.getInstance().flush() - val cookie = CookieEntity(prefs.userId, null, webCookie) - cookieDao.save(cookie) + withContext(Dispatchers.Main + NonCancellable) { + L.d { "Switching User" } + prefs.userId = cookie.id + CookieManager.getInstance().apply { + removeAllCookies() + suspendSetWebCookie(FB_COOKIE_DOMAIN, cookie.cookie) + suspendSetWebCookie(MESSENGER_COOKIE_DOMAIN, cookie.cookieMessenger) + flush() + } } + } - suspend fun reset() { - prefs.userId = -1L - withContext(Dispatchers.Main + NonCancellable) { - with(CookieManager.getInstance()) { - removeAllCookies() - flush() - } - } - } + /** Helper function to remove the current cookies and launch the proper login page */ + suspend fun logout(context: Context, deleteCookie: Boolean = true) { + val cookies = arrayListOf() + if (context is Activity) cookies.addAll(context.cookies().filter { it.id != prefs.userId }) + logout(prefs.userId, deleteCookie) + context.launchLogin(cookies, true) + } - suspend fun switchUser(id: Long) { - val cookie = cookieDao.selectById(id) ?: return L.e { "No cookie for id" } - switchUser(cookie) + /** Clear the cookies of the given id */ + suspend fun logout(id: Long, deleteCookie: Boolean = true) { + L.d { "Logging out user" } + if (deleteCookie) { + cookieDao.deleteById(id) + L.d { "Fb cookie deleted" } + L._d { id } } + reset() + } - suspend fun switchUser(cookie: CookieEntity?) { - if (cookie?.cookie == null) { - L.d { "Switching User; null cookie" } - return - } - withContext(Dispatchers.Main + NonCancellable) { - L.d { "Switching User" } - prefs.userId = cookie.id - CookieManager.getInstance().apply { - removeAllCookies() - suspendSetWebCookie(FB_COOKIE_DOMAIN, cookie.cookie) - suspendSetWebCookie(MESSENGER_COOKIE_DOMAIN, cookie.cookieMessenger) - flush() - } - } - } - - /** - * Helper function to remove the current cookies - * and launch the proper login page - */ - suspend fun logout(context: Context, deleteCookie: Boolean = true) { - val cookies = arrayListOf() - if (context is Activity) - cookies.addAll(context.cookies().filter { it.id != prefs.userId }) - logout(prefs.userId, deleteCookie) - context.launchLogin(cookies, true) - } - - /** - * Clear the cookies of the given id - */ - suspend fun logout(id: Long, deleteCookie: Boolean = true) { - L.d { "Logging out user" } - if (deleteCookie) { - cookieDao.deleteById(id) - L.d { "Fb cookie deleted" } - L._d { id } - } - reset() - } - - /** - * Notifications may come from different accounts, and we need to switch the cookies to load them - * When coming back to the main app, switch back to our original account before continuing - */ - suspend fun switchBackUser() { - if (prefs.prevId == -1L) return - val prevId = prefs.prevId - prefs.prevId = -1L - if (prevId != prefs.userId) { - switchUser(prevId) - L.d { "Switch back user" } - L._d { "${prefs.userId} to $prevId" } - } + /** + * Notifications may come from different accounts, and we need to switch the cookies to load them + * When coming back to the main app, switch back to our original account before continuing + */ + suspend fun switchBackUser() { + if (prefs.prevId == -1L) return + val prevId = prefs.prevId + prefs.prevId = -1L + if (prevId != prefs.userId) { + switchUser(prevId) + L.d { "Switch back user" } + L._d { "${prefs.userId} to $prevId" } } + } } diff --git a/app/src/main/kotlin/com/pitchedapps/frost/facebook/FbItem.kt b/app/src/main/kotlin/com/pitchedapps/frost/facebook/FbItem.kt index 7d9d5c128..9559bf0bc 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/facebook/FbItem.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/facebook/FbItem.kt @@ -29,79 +29,73 @@ import com.pitchedapps.frost.utils.EnumBundleCompanion import com.pitchedapps.frost.utils.EnumCompanion enum class FbItem( - @StringRes val titleId: Int, - val icon: IIcon, - relativeUrl: String, - val fragmentCreator: () -> BaseFragment = ::WebFragment, - prefix: String = FB_URL_BASE + @StringRes val titleId: Int, + val icon: IIcon, + relativeUrl: String, + val fragmentCreator: () -> BaseFragment = ::WebFragment, + prefix: String = FB_URL_BASE ) : EnumBundle { + ACTIVITY_LOG(R.string.activity_log, GoogleMaterial.Icon.gmd_list, "me/allactivity"), + BIRTHDAYS(R.string.birthdays, GoogleMaterial.Icon.gmd_cake, "events/birthdays"), + CHAT(R.string.chat, GoogleMaterial.Icon.gmd_chat, "buddylist"), + EVENTS(R.string.events, GoogleMaterial.Icon.gmd_event_note, "events/upcoming"), + FEED(R.string.feed, CommunityMaterial.Icon3.cmd_newspaper, ""), + FEED_MOST_RECENT(R.string.most_recent, GoogleMaterial.Icon.gmd_history, "home.php?sk=h_chr"), + FEED_TOP_STORIES(R.string.top_stories, GoogleMaterial.Icon.gmd_star, "home.php?sk=h_nor"), + FRIENDS(R.string.friends, GoogleMaterial.Icon.gmd_person_add, "friends/center/requests"), + GROUPS(R.string.groups, GoogleMaterial.Icon.gmd_group, "groups"), + MARKETPLACE(R.string.marketplace, GoogleMaterial.Icon.gmd_store, "marketplace"), - ACTIVITY_LOG(R.string.activity_log, GoogleMaterial.Icon.gmd_list, "me/allactivity"), - BIRTHDAYS(R.string.birthdays, GoogleMaterial.Icon.gmd_cake, "events/birthdays"), - CHAT(R.string.chat, GoogleMaterial.Icon.gmd_chat, "buddylist"), - EVENTS(R.string.events, GoogleMaterial.Icon.gmd_event_note, "events/upcoming"), - FEED(R.string.feed, CommunityMaterial.Icon3.cmd_newspaper, ""), - FEED_MOST_RECENT(R.string.most_recent, GoogleMaterial.Icon.gmd_history, "home.php?sk=h_chr"), - FEED_TOP_STORIES(R.string.top_stories, GoogleMaterial.Icon.gmd_star, "home.php?sk=h_nor"), - FRIENDS(R.string.friends, GoogleMaterial.Icon.gmd_person_add, "friends/center/requests"), - GROUPS(R.string.groups, GoogleMaterial.Icon.gmd_group, "groups"), - MARKETPLACE(R.string.marketplace, GoogleMaterial.Icon.gmd_store, "marketplace"), + /* + * Unlike other urls, menus cannot be linked directly as it is a soft reference. Instead, we can + * pick any url with the blue bar and manually click to enter the menu. + * We pick home.php as some back interactions default to home regardless of the base url. + */ + MENU(R.string.menu, GoogleMaterial.Icon.gmd_menu, "home.php"), + MESSAGES(R.string.messages, MaterialDesignIconic.Icon.gmi_comments, "messages"), + MESSENGER( + R.string.messenger, + CommunityMaterial.Icon2.cmd_facebook_messenger, + "", + prefix = HTTPS_MESSENGER_COM + ), + NOTES(R.string.notes, CommunityMaterial.Icon3.cmd_note, "notes"), + NOTIFICATIONS(R.string.notifications, MaterialDesignIconic.Icon.gmi_globe, "notifications"), + ON_THIS_DAY(R.string.on_this_day, GoogleMaterial.Icon.gmd_today, "onthisday"), + PAGES(R.string.pages, GoogleMaterial.Icon.gmd_flag, "pages"), + PHOTOS(R.string.photos, GoogleMaterial.Icon.gmd_photo, "me/photos"), + PROFILE(R.string.profile, CommunityMaterial.Icon.cmd_account, "me"), + SAVED(R.string.saved, GoogleMaterial.Icon.gmd_bookmark, "saved"), - /* - * Unlike other urls, menus cannot be linked directly as it is a soft reference. Instead, we can - * pick any url with the blue bar and manually click to enter the menu. - * We pick home.php as some back interactions default to home regardless of the base url. - */ - MENU(R.string.menu, GoogleMaterial.Icon.gmd_menu, "home.php"), - MESSAGES(R.string.messages, MaterialDesignIconic.Icon.gmi_comments, "messages"), - MESSENGER( - R.string.messenger, - CommunityMaterial.Icon2.cmd_facebook_messenger, - "", - prefix = HTTPS_MESSENGER_COM - ), - NOTES(R.string.notes, CommunityMaterial.Icon3.cmd_note, "notes"), - NOTIFICATIONS(R.string.notifications, MaterialDesignIconic.Icon.gmi_globe, "notifications"), - ON_THIS_DAY(R.string.on_this_day, GoogleMaterial.Icon.gmd_today, "onthisday"), - PAGES(R.string.pages, GoogleMaterial.Icon.gmd_flag, "pages"), - PHOTOS(R.string.photos, GoogleMaterial.Icon.gmd_photo, "me/photos"), - PROFILE(R.string.profile, CommunityMaterial.Icon.cmd_account, "me"), - SAVED(R.string.saved, GoogleMaterial.Icon.gmd_bookmark, "saved"), + /** Note that this url only works if a query (?q=) is provided */ + _SEARCH(R.string.kau_search, GoogleMaterial.Icon.gmd_search, "search/top"), - /** - * Note that this url only works if a query (?q=) is provided - */ - _SEARCH( - R.string.kau_search, - GoogleMaterial.Icon.gmd_search, - "search/top" - ), + /** Non mbasic search cannot be parsed. */ + _SEARCH_PARSE( + R.string.kau_search, + GoogleMaterial.Icon.gmd_search, + "search/top", + prefix = FB_URL_MBASIC_BASE + ), + SETTINGS(R.string.settings, GoogleMaterial.Icon.gmd_settings, "settings"), + ; - /** - * Non mbasic search cannot be parsed. - */ - _SEARCH_PARSE( - R.string.kau_search, - GoogleMaterial.Icon.gmd_search, - "search/top", - prefix = FB_URL_MBASIC_BASE - ), - SETTINGS(R.string.settings, GoogleMaterial.Icon.gmd_settings, "settings"), - ; + val url = "$prefix$relativeUrl" - val url = "$prefix$relativeUrl" + val isFeed: Boolean + get() = + when (this) { + FEED, + FEED_MOST_RECENT, + FEED_TOP_STORIES -> true + else -> false + } - val isFeed: Boolean - get() = when (this) { - FEED, FEED_MOST_RECENT, FEED_TOP_STORIES -> true - else -> false - } + override val bundleContract: EnumBundleCompanion + get() = Companion - override val bundleContract: EnumBundleCompanion - get() = Companion - - companion object : EnumCompanion("frost_arg_fb_item", values()) + companion object : EnumCompanion("frost_arg_fb_item", values()) } fun defaultTabs(): List = - listOf(FbItem.FEED, FbItem.MESSAGES, FbItem.NOTIFICATIONS, FbItem.MENU) + listOf(FbItem.FEED, FbItem.MESSAGES, FbItem.NOTIFICATIONS, FbItem.MENU) diff --git a/app/src/main/kotlin/com/pitchedapps/frost/facebook/FbRegex.kt b/app/src/main/kotlin/com/pitchedapps/frost/facebook/FbRegex.kt index 2c987a485..b343b2df9 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/facebook/FbRegex.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/facebook/FbRegex.kt @@ -19,21 +19,16 @@ package com.pitchedapps.frost.facebook /** * Created by Allan Wang on 21/12/17. * - * Collection of regex matchers - * Input text must be properly unescaped + * Collection of regex matchers Input text must be properly unescaped * * See [StringEscapeUtils] */ -/** - * Matches the fb_dtsg component of a page containing it as a hidden value - */ +/** Matches the fb_dtsg component of a page containing it as a hidden value */ val FB_DTSG_MATCHER: Regex by lazy { Regex("name=\"fb_dtsg\" value=\"(.*?)\"") } val FB_REV_MATCHER: Regex by lazy { Regex("\"app_version\":\"(.*?)\"") } -/** - * Matches user id from cookie - */ +/** Matches user id from cookie */ val FB_USER_MATCHER: Regex = Regex("c_user=([0-9]*);") val FB_EPOCH_MATCHER: Regex = Regex(":([0-9]+)") diff --git a/app/src/main/kotlin/com/pitchedapps/frost/facebook/FbUrlFormatter.kt b/app/src/main/kotlin/com/pitchedapps/frost/facebook/FbUrlFormatter.kt index 8ee857520..ed90390b1 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/facebook/FbUrlFormatter.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/facebook/FbUrlFormatter.kt @@ -28,141 +28,164 @@ import java.nio.charset.StandardCharsets * Custom url builder so we can easily test it without the Android framework */ inline val String.formattedFbUrl: String - get() = FbUrlFormatter(this).toString() + get() = FbUrlFormatter(this).toString() inline val Uri.formattedFbUri: Uri - get() { - val url = toString() - return if (url.startsWith("http")) Uri.parse(url.formattedFbUrl) else this - } + get() { + val url = toString() + return if (url.startsWith("http")) Uri.parse(url.formattedFbUrl) else this + } class FbUrlFormatter(url: String) { - private val queries = mutableMapOf() - private val cleaned: String + private val queries = mutableMapOf() + private val cleaned: String - /** - * Formats all facebook urls - * - * The order is very important: - * 1. Wrapper links (discardables) are stripped away, resulting in the actual link - * 2. CSS encoding is converted to normal encoding - * 3. Url is completely decoded - * 4. Url is split into sections - */ - init { - cleaned = clean(url) + /** + * Formats all facebook urls + * + * The order is very important: + * 1. Wrapper links (discardables) are stripped away, resulting in the actual link + * 2. CSS encoding is converted to normal encoding + * 3. Url is completely decoded + * 4. Url is split into sections + */ + init { + cleaned = clean(url) + } + + fun clean(url: String): String { + if (url.isBlank()) return "" + var cleanedUrl = url + if (cleanedUrl.startsWith("#!")) cleanedUrl = cleanedUrl.substring(2) + val urlRef = cleanedUrl + discardable.forEach { cleanedUrl = cleanedUrl.replace(it, "", true) } + val changed = cleanedUrl != urlRef + converter.forEach { (k, v) -> cleanedUrl = cleanedUrl.replace(k, v, true) } + try { + cleanedUrl = URLDecoder.decode(cleanedUrl, StandardCharsets.UTF_8.name()) + } catch (e: Exception) { + L.e(e) { "Failed url formatting" } + return url } - - fun clean(url: String): String { - if (url.isBlank()) return "" - var cleanedUrl = url - if (cleanedUrl.startsWith("#!")) cleanedUrl = cleanedUrl.substring(2) - val urlRef = cleanedUrl - discardable.forEach { cleanedUrl = cleanedUrl.replace(it, "", true) } - val changed = cleanedUrl != urlRef - converter.forEach { (k, v) -> cleanedUrl = cleanedUrl.replace(k, v, true) } - try { - cleanedUrl = URLDecoder.decode(cleanedUrl, StandardCharsets.UTF_8.name()) - } catch (e: Exception) { - L.e(e) { "Failed url formatting" } - return url - } - cleanedUrl = cleanedUrl.replace("&", "&") - if (changed && !cleanedUrl.contains("?")) // ensure we aren't missing '?' - cleanedUrl = cleanedUrl.replaceFirst("&", "?") - val qm = cleanedUrl.indexOf("?") - if (qm > -1) { - cleanedUrl.substring(qm + 1).split("&").forEach { - val p = it.split("=") - queries[p[0]] = p.elementAtOrNull(1) ?: "" - } - cleanedUrl = cleanedUrl.substring(0, qm) - } - discardableQueries.forEach { queries.remove(it) } - // Convert desktop urls to mobile ones - cleanedUrl = cleanedUrl.replace(WWW_FACEBOOK_COM, FACEBOOK_BASE_COM) - if (cleanedUrl.startsWith("/")) cleanedUrl = FB_URL_BASE + cleanedUrl.substring(1) - cleanedUrl = cleanedUrl.replaceFirst( - ".facebook.com//", - ".facebook.com/" - ) // sometimes we are given a bad url - L.v { "Formatted url from $url to $cleanedUrl" } - return cleanedUrl + cleanedUrl = cleanedUrl.replace("&", "&") + if (changed && !cleanedUrl.contains("?")) // ensure we aren't missing '?' + cleanedUrl = cleanedUrl.replaceFirst("&", "?") + val qm = cleanedUrl.indexOf("?") + if (qm > -1) { + cleanedUrl.substring(qm + 1).split("&").forEach { + val p = it.split("=") + queries[p[0]] = p.elementAtOrNull(1) ?: "" + } + cleanedUrl = cleanedUrl.substring(0, qm) } + discardableQueries.forEach { queries.remove(it) } + // Convert desktop urls to mobile ones + cleanedUrl = cleanedUrl.replace(WWW_FACEBOOK_COM, FACEBOOK_BASE_COM) + if (cleanedUrl.startsWith("/")) cleanedUrl = FB_URL_BASE + cleanedUrl.substring(1) + cleanedUrl = + cleanedUrl.replaceFirst( + ".facebook.com//", + ".facebook.com/" + ) // sometimes we are given a bad url + L.v { "Formatted url from $url to $cleanedUrl" } + return cleanedUrl + } - override fun toString(): String = buildString { + override fun toString(): String = + buildString { append(cleaned) if (queries.isNotEmpty()) { - append("?") - queries.forEach { (k, v) -> - if (v.isEmpty()) { - append("${k.urlEncode()}&") - } else { - append("${k.urlEncode()}=${v.urlEncode()}&") - } + append("?") + queries.forEach { (k, v) -> + if (v.isEmpty()) { + append("${k.urlEncode()}&") + } else { + append("${k.urlEncode()}=${v.urlEncode()}&") } + } } - }.removeSuffix("&") + } + .removeSuffix("&") - fun toLogList(): List { - val list = mutableListOf(cleaned) - queries.forEach { (k, v) -> list.add("\n- $k\t=\t$v") } - list.add("\n\n${toString()}") - return list - } + fun toLogList(): List { + val list = mutableListOf(cleaned) + queries.forEach { (k, v) -> list.add("\n- $k\t=\t$v") } + list.add("\n\n${toString()}") + return list + } - companion object { + companion object { - const val VIDEO_REDIRECT = "/video_redirect/?src=" + const val VIDEO_REDIRECT = "/video_redirect/?src=" - /** - * Items here are explicitly removed from the url - * Taken from FaceSlim - * https://github.com/indywidualny/FaceSlim/blob/master/app/src/main/java/org/indywidualni/fblite/util/Miscellany.java - * - * Note: Typically, in this case, the redirect url should have all the necessary queries - * I am unsure how Facebook reacts in all cases, so the ones after the redirect are appended on afterwards - * That shouldn't break anything - */ - val discardable = arrayOf( - "http://lm.facebook.com/l.php?u=", - "https://lm.facebook.com/l.php?u=", - "http://m.facebook.com/l.php?u=", - "https://m.facebook.com/l.php?u=", - "http://touch.facebook.com/l.php?u=", - "https://touch.facebook.com/l.php?u=", - VIDEO_REDIRECT - ) + /** + * Items here are explicitly removed from the url Taken from FaceSlim + * https://github.com/indywidualny/FaceSlim/blob/master/app/src/main/java/org/indywidualni/fblite/util/Miscellany.java + * + * Note: Typically, in this case, the redirect url should have all the necessary queries I am + * unsure how Facebook reacts in all cases, so the ones after the redirect are appended on + * afterwards That shouldn't break anything + */ + val discardable = + arrayOf( + "http://lm.facebook.com/l.php?u=", + "https://lm.facebook.com/l.php?u=", + "http://m.facebook.com/l.php?u=", + "https://m.facebook.com/l.php?u=", + "http://touch.facebook.com/l.php?u=", + "https://touch.facebook.com/l.php?u=", + VIDEO_REDIRECT + ) - /** - * Queries that are not necessary for independent links - * - * acontext is not required for "friends interested in" notifications - */ - val discardableQueries = arrayOf( - "ref", - "refid", - "SharedWith", - "fbclid", - "h", - "_ft_", - "_tn_", - "_xt_", - "bacr", - "frefs", - "hc_ref", - "loc_ref", - "pn_ref" - ) + /** + * Queries that are not necessary for independent links + * + * acontext is not required for "friends interested in" notifications + */ + val discardableQueries = + arrayOf( + "ref", + "refid", + "SharedWith", + "fbclid", + "h", + "_ft_", + "_tn_", + "_xt_", + "bacr", + "frefs", + "hc_ref", + "loc_ref", + "pn_ref" + ) - val converter = listOf( - "\\3C " to "%3C", "\\3E " to "%3E", "\\23 " to "%23", "\\25 " to "%25", - "\\7B " to "%7B", "\\7D " to "%7D", "\\7C " to "%7C", "\\5C " to "%5C", - "\\5E " to "%5E", "\\7E " to "%7E", "\\5B " to "%5B", "\\5D " to "%5D", - "\\60 " to "%60", "\\3B " to "%3B", "\\2F " to "%2F", "\\3F " to "%3F", - "\\3A " to "%3A", "\\40 " to "%40", "\\3D " to "%3D", "\\26 " to "%26", - "\\24 " to "%24", "\\2B " to "%2B", "\\22 " to "%22", "\\2C " to "%2C", - "\\20 " to "%20" - ) - } + val converter = + listOf( + "\\3C " to "%3C", + "\\3E " to "%3E", + "\\23 " to "%23", + "\\25 " to "%25", + "\\7B " to "%7B", + "\\7D " to "%7D", + "\\7C " to "%7C", + "\\5C " to "%5C", + "\\5E " to "%5E", + "\\7E " to "%7E", + "\\5B " to "%5B", + "\\5D " to "%5D", + "\\60 " to "%60", + "\\3B " to "%3B", + "\\2F " to "%2F", + "\\3F " to "%3F", + "\\3A " to "%3A", + "\\40 " to "%40", + "\\3D " to "%3D", + "\\26 " to "%26", + "\\24 " to "%24", + "\\2B " to "%2B", + "\\22 " to "%22", + "\\2C " to "%2C", + "\\20 " to "%20" + ) + } } diff --git a/app/src/main/kotlin/com/pitchedapps/frost/facebook/parsers/BadgeParser.kt b/app/src/main/kotlin/com/pitchedapps/frost/facebook/parsers/BadgeParser.kt index b4fb54a6f..dbdb31a36 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/facebook/parsers/BadgeParser.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/facebook/parsers/BadgeParser.kt @@ -23,42 +23,38 @@ import org.jsoup.nodes.Document object BadgeParser : FrostParser by BadgeParserImpl() data class FrostBadges( - val feed: String?, - val friends: String?, - val messages: String?, - val notifications: String? + val feed: String?, + val friends: String?, + val messages: String?, + val notifications: String? ) : ParseData { - override val isEmpty: Boolean - get() = feed.isNullOrEmpty() && - friends.isNullOrEmpty() && - messages.isNullOrEmpty() && - notifications.isNullOrEmpty() + override val isEmpty: Boolean + get() = + feed.isNullOrEmpty() && + friends.isNullOrEmpty() && + messages.isNullOrEmpty() && + notifications.isNullOrEmpty() } private class BadgeParserImpl : FrostParserBase(false) { - // Not actually displayed - override var nameRes: Int = R.string.frost_name + // Not actually displayed + override var nameRes: Int = R.string.frost_name - override val url: String = FB_URL_BASE + override val url: String = FB_URL_BASE - override fun parseImpl(doc: Document): FrostBadges? { - val header = doc.getElementById("header") ?: return null - if (header.select("[data-sigil=count]").isEmpty()) - return null - val (feed, requests, messages, notifications) = listOf( - "feed", - "requests", - "messages", - "notifications" - ) - .map { "[data-sigil*=$it] [data-sigil=count]" } - .map { doc.select(it) } - .map { e -> e?.getOrNull(0)?.ownText() } - return FrostBadges( - feed = feed, - friends = requests, - messages = messages, - notifications = notifications - ) - } + override fun parseImpl(doc: Document): FrostBadges? { + val header = doc.getElementById("header") ?: return null + if (header.select("[data-sigil=count]").isEmpty()) return null + val (feed, requests, messages, notifications) = + listOf("feed", "requests", "messages", "notifications") + .map { "[data-sigil*=$it] [data-sigil=count]" } + .map { doc.select(it) } + .map { e -> e?.getOrNull(0)?.ownText() } + return FrostBadges( + feed = feed, + friends = requests, + messages = messages, + notifications = notifications + ) + } } diff --git a/app/src/main/kotlin/com/pitchedapps/frost/facebook/parsers/FrostParser.kt b/app/src/main/kotlin/com/pitchedapps/frost/facebook/parsers/FrostParser.kt index 1240614b2..0c55f4390 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/facebook/parsers/FrostParser.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/facebook/parsers/FrostParser.kt @@ -30,46 +30,33 @@ import org.jsoup.select.Elements /** * Created by Allan Wang on 2017-10-06. * - * Interface for a given parser - * Use cases should be attached as delegates to objects that implement this interface + * Interface for a given parser Use cases should be attached as delegates to objects that implement + * this interface * - * In all cases, parsing will be done from a JSoup document - * Variants accepting strings are also permitted, and they will be converted to documents accordingly - * The return type must be nonnull if no parsing errors occurred, as null signifies a parse error - * If null really must be allowed, use Optionals + * In all cases, parsing will be done from a JSoup document Variants accepting strings are also + * permitted, and they will be converted to documents accordingly The return type must be nonnull if + * no parsing errors occurred, as null signifies a parse error If null really must be allowed, use + * Optionals */ interface FrostParser { - /** - * Name associated to parser - * Purely for display - */ - var nameRes: Int + /** Name associated to parser Purely for display */ + var nameRes: Int - /** - * Url to request from - */ - val url: String + /** Url to request from */ + val url: String - /** - * Call parsing with default implementation using cookie - */ - fun parse(cookie: String?): ParseResponse? + /** Call parsing with default implementation using cookie */ + fun parse(cookie: String?): ParseResponse? - /** - * Call parsing with given document - */ - fun parse(cookie: String?, document: Document): ParseResponse? + /** Call parsing with given document */ + fun parse(cookie: String?, document: Document): ParseResponse? - /** - * Call parsing using jsoup to fetch from given url - */ - fun parseFromUrl(cookie: String?, url: String): ParseResponse? + /** Call parsing using jsoup to fetch from given url */ + fun parseFromUrl(cookie: String?, url: String): ParseResponse? - /** - * Call parsing with given data - */ - fun parseFromData(cookie: String?, text: String): ParseResponse? + /** Call parsing with given data */ + fun parseFromData(cookie: String?, text: String): ParseResponse? } const val FALLBACK_TIME_MOD = 1000000 @@ -77,69 +64,73 @@ const val FALLBACK_TIME_MOD = 1000000 data class FrostLink(val text: String, val href: String) data class ParseResponse(val cookie: String, val data: T) { - override fun toString() = "ParseResponse\ncookie: $cookie\ndata:\n$data" + override fun toString() = "ParseResponse\ncookie: $cookie\ndata:\n$data" } interface ParseData { - val isEmpty: Boolean + val isEmpty: Boolean } interface ParseNotification : ParseData { - fun getUnreadNotifications(data: CookieEntity): List + fun getUnreadNotifications(data: CookieEntity): List } -internal fun List.toJsonString(tag: String, indent: Int) = StringBuilder().apply { - val tabs = "\t".repeat(indent) - append("$tabs$tag: [\n\t$tabs") - append(this@toJsonString.joinToString("\n\t$tabs")) - append("\n$tabs]\n") -}.toString() +internal fun List.toJsonString(tag: String, indent: Int) = + StringBuilder() + .apply { + val tabs = "\t".repeat(indent) + append("$tabs$tag: [\n\t$tabs") + append(this@toJsonString.joinToString("\n\t$tabs")) + append("\n$tabs]\n") + } + .toString() /** - * T should have a readable toString() function - * [redirectToText] dictates whether all data should be converted to text then back to document before parsing + * T should have a readable toString() function [redirectToText] dictates whether all data should be + * converted to text then back to document before parsing */ internal abstract class FrostParserBase(private val redirectToText: Boolean) : - FrostParser { + FrostParser { - final override fun parse(cookie: String?) = parseFromUrl(cookie, url) + final override fun parse(cookie: String?) = parseFromUrl(cookie, url) - final override fun parseFromData(cookie: String?, text: String): ParseResponse? { - cookie ?: return null - val doc = textToDoc(text) ?: return null - val data = parseImpl(doc) ?: return null - return ParseResponse(cookie, data) - } + final override fun parseFromData(cookie: String?, text: String): ParseResponse? { + cookie ?: return null + val doc = textToDoc(text) ?: return null + val data = parseImpl(doc) ?: return null + return ParseResponse(cookie, data) + } - final override fun parseFromUrl(cookie: String?, url: String): ParseResponse? = - parse(cookie, frostJsoup(cookie, url)) + final override fun parseFromUrl(cookie: String?, url: String): ParseResponse? = + parse(cookie, frostJsoup(cookie, url)) - override fun parse(cookie: String?, document: Document): ParseResponse? { - cookie ?: return null - if (redirectToText) - return parseFromData(cookie, document.toString()) - val data = parseImpl(document) ?: return null - return ParseResponse(cookie, data) - } + override fun parse(cookie: String?, document: Document): ParseResponse? { + cookie ?: return null + if (redirectToText) return parseFromData(cookie, document.toString()) + val data = parseImpl(document) ?: return null + return ParseResponse(cookie, data) + } - protected abstract fun parseImpl(doc: Document): T? + protected abstract fun parseImpl(doc: Document): T? - /** - * Attempts to find inner element with some style containing a url - * Returns the formatted url, or an empty string if nothing was found - */ - protected fun Element.getInnerImgStyle(): String? = - select("i.img[style*=url]").getStyleUrl() + /** + * Attempts to find inner element with some style containing a url Returns the formatted url, + * or an empty string if nothing was found + */ + protected fun Element.getInnerImgStyle(): String? = select("i.img[style*=url]").getStyleUrl() - protected fun Elements.getStyleUrl(): String? = - FB_CSS_URL_MATCHER.find(attr("style"))[1]?.formattedFbUrl + protected fun Elements.getStyleUrl(): String? = + FB_CSS_URL_MATCHER.find(attr("style"))[1]?.formattedFbUrl - protected open fun textToDoc(text: String): Document? = - if (!redirectToText) Jsoup.parse(text) - else throw RuntimeException("${this::class.java.simpleName} requires text redirect but did not implement textToDoc") + protected open fun textToDoc(text: String): Document? = + if (!redirectToText) Jsoup.parse(text) + else + throw RuntimeException( + "${this::class.java.simpleName} requires text redirect but did not implement textToDoc" + ) - protected fun parseLink(element: Element?): FrostLink? { - val a = element?.getElementsByTag("a")?.first() ?: return null - return FrostLink(a.text(), a.attr("href")) - } + protected fun parseLink(element: Element?): FrostLink? { + val a = element?.getElementsByTag("a")?.first() ?: return null + return FrostLink(a.text(), a.attr("href")) + } } diff --git a/app/src/main/kotlin/com/pitchedapps/frost/facebook/parsers/MessageParser.kt b/app/src/main/kotlin/com/pitchedapps/frost/facebook/parsers/MessageParser.kt index ef56aa7bc..025d9f393 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/facebook/parsers/MessageParser.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/facebook/parsers/MessageParser.kt @@ -33,126 +33,128 @@ import org.jsoup.nodes.Element /** * Created by Allan Wang on 2017-10-06. * - * In Facebook, messages are passed through scripts and loaded into view via react afterwards - * We can parse out the content we want directly and load it ourselves - * + * In Facebook, messages are passed through scripts and loaded into view via react afterwards We can + * parse out the content we want directly and load it ourselves */ object MessageParser : FrostParser by MessageParserImpl() { - fun queryUser(cookie: String?, name: String) = - parseFromUrl(cookie, "${FbItem.MESSAGES.url}/?q=${name.urlEncode()}") + fun queryUser(cookie: String?, name: String) = + parseFromUrl(cookie, "${FbItem.MESSAGES.url}/?q=${name.urlEncode()}") } data class FrostMessages( - val threads: List, - val seeMore: FrostLink?, - val extraLinks: List + val threads: List, + val seeMore: FrostLink?, + val extraLinks: List ) : ParseNotification { - override val isEmpty: Boolean - get() = threads.isEmpty() + override val isEmpty: Boolean + get() = threads.isEmpty() - override fun toString() = StringBuilder().apply { + override fun toString() = + StringBuilder() + .apply { append("FrostMessages {\n") append(threads.toJsonString("threads", 1)) append("\tsee more: $seeMore\n") append(extraLinks.toJsonString("extra links", 1)) append("}") - }.toString() + } + .toString() - override fun getUnreadNotifications(data: CookieEntity) = - threads.asSequence().filter(FrostThread::unread).map { - with(it) { - NotificationContent( - data = data, - id = id, - href = url, - title = title, - text = content ?: "", - timestamp = time, - profileUrl = img, - unread = unread - ) - } - }.toList() + override fun getUnreadNotifications(data: CookieEntity) = + threads + .asSequence() + .filter(FrostThread::unread) + .map { + with(it) { + NotificationContent( + data = data, + id = id, + href = url, + title = title, + text = content ?: "", + timestamp = time, + profileUrl = img, + unread = unread + ) + } + } + .toList() } /** - * [id] user/thread id, or current time fallback - * [img] parsed url for profile img - * [time] time of message - * [url] link to thread - * [unread] true if image is unread, false otherwise - * [content] optional string for thread + * [id] user/thread id, or current time fallback [img] parsed url for profile img [time] time of + * message [url] link to thread [unread] true if image is unread, false otherwise [content] optional + * string for thread */ data class FrostThread( - val id: Long, - val img: String?, - val title: String, - val time: Long, - val url: String, - val unread: Boolean, - val content: String?, - val contentImgUrl: String? + val id: Long, + val img: String?, + val title: String, + val time: Long, + val url: String, + val unread: Boolean, + val content: String?, + val contentImgUrl: String? ) private class MessageParserImpl : FrostParserBase(true) { - override var nameRes = FbItem.MESSAGES.titleId + override var nameRes = FbItem.MESSAGES.titleId - override val url = FbItem.MESSAGES.url + override val url = FbItem.MESSAGES.url - override fun textToDoc(text: String): Document? { - var content = StringEscapeUtils.unescapeEcmaScript(text) - val begin = content.indexOf("id=\"threadlist_rows\"") - if (begin <= 0) { - L.d { "Threadlist not found" } - return null - } - content = content.substring(begin) - val end = content.indexOf("") - if (end <= 0) { - L.d { "Script tail not found" } - return null - } - content = content.substring(0, end).substringBeforeLast("") - return Jsoup.parseBodyFragment("
= - threadList.getElementsByAttributeValueMatching( - "id", - ".*${FB_MESSAGE_NOTIF_ID_MATCHER.pattern}.*" - ) - .mapNotNull(this::parseMessage) - val seeMore = parseLink(doc.getElementById("see_older_threads")) - val extraLinks = threadList.nextElementSibling()?.select("a") - ?.mapNotNull(this::parseLink) ?: emptyList() - return FrostMessages(threads, seeMore, extraLinks) + content = content.substring(begin) + val end = content.indexOf("") + if (end <= 0) { + L.d { "Script tail not found" } + return null } + content = content.substring(0, end).substringBeforeLast("
") + return Jsoup.parseBodyFragment("
= + threadList + .getElementsByAttributeValueMatching("id", ".*${FB_MESSAGE_NOTIF_ID_MATCHER.pattern}.*") + .mapNotNull(this::parseMessage) + val seeMore = parseLink(doc.getElementById("see_older_threads")) + val extraLinks = + threadList.nextElementSibling()?.select("a")?.mapNotNull(this::parseLink) ?: emptyList() + return FrostMessages(threads, seeMore, extraLinks) + } + + private fun parseMessage(element: Element): FrostThread? { + val a = element.getElementsByTag("a").first() ?: return null + val abbr = element.getElementsByTag("abbr") + val epoch = FB_EPOCH_MATCHER.find(abbr.attr("data-store"))[1]?.toLongOrNull() ?: -1L + // fetch id + val id = + FB_MESSAGE_NOTIF_ID_MATCHER.find(element.id())[1]?.toLongOrNull() + ?: System.currentTimeMillis() % FALLBACK_TIME_MOD + val snippet = element.select("span.snippet").firstOrNull() + val content = snippet?.text()?.trim() + val contentImg = snippet?.select("i[style*=url]")?.getStyleUrl() + val img = element.getInnerImgStyle() + return FrostThread( + id = id, + img = img, + title = a.text(), + time = epoch, + url = a.attr("href").formattedFbUrl, + unread = !element.hasClass("acw"), + content = content, + contentImgUrl = contentImg + ) + } } diff --git a/app/src/main/kotlin/com/pitchedapps/frost/facebook/parsers/NotifParser.kt b/app/src/main/kotlin/com/pitchedapps/frost/facebook/parsers/NotifParser.kt index af342d04e..7d279e305 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/facebook/parsers/NotifParser.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/facebook/parsers/NotifParser.kt @@ -26,103 +26,101 @@ import com.pitchedapps.frost.services.NotificationContent import org.jsoup.nodes.Document import org.jsoup.nodes.Element -/** - * Created by Allan Wang on 2017-12-25. - * - */ +/** Created by Allan Wang on 2017-12-25. */ object NotifParser : FrostParser by NotifParserImpl() -data class FrostNotifs( - val notifs: List, - val seeMore: FrostLink? -) : ParseNotification { +data class FrostNotifs(val notifs: List, val seeMore: FrostLink?) : ParseNotification { - override val isEmpty: Boolean - get() = notifs.isEmpty() + override val isEmpty: Boolean + get() = notifs.isEmpty() - override fun toString() = StringBuilder().apply { + override fun toString() = + StringBuilder() + .apply { append("FrostNotifs {\n") append(notifs.toJsonString("notifs", 1)) append("\tsee more: $seeMore\n") append("}") - }.toString() + } + .toString() - override fun getUnreadNotifications(data: CookieEntity) = - notifs.asSequence().filter(FrostNotif::unread).map { - with(it) { - NotificationContent( - data = data, - id = id, - href = url, - title = null, - text = content, - timestamp = time, - profileUrl = img, - unread = unread - ) - } - }.toList() + override fun getUnreadNotifications(data: CookieEntity) = + notifs + .asSequence() + .filter(FrostNotif::unread) + .map { + with(it) { + NotificationContent( + data = data, + id = id, + href = url, + title = null, + text = content, + timestamp = time, + profileUrl = img, + unread = unread + ) + } + } + .toList() } /** - * [id] notif id, or current time fallback - * [img] parsed url for profile img - * [time] time of message - * [url] link to thread - * [unread] true if image is unread, false otherwise - * [content] optional string for thread - * [timeString] text version of time from Facebook - * [thumbnailUrl] optional thumbnail url if existent + * [id] notif id, or current time fallback [img] parsed url for profile img [time] time of message + * [url] link to thread [unread] true if image is unread, false otherwise [content] optional string + * for thread [timeString] text version of time from Facebook [thumbnailUrl] optional thumbnail url + * if existent */ data class FrostNotif( - val id: Long, - val img: String?, - val time: Long, - val url: String, - val unread: Boolean, - val content: String, - val timeString: String, - val thumbnailUrl: String? + val id: Long, + val img: String?, + val time: Long, + val url: String, + val unread: Boolean, + val content: String, + val timeString: String, + val thumbnailUrl: String? ) private class NotifParserImpl : FrostParserBase(false) { - override var nameRes = FbItem.NOTIFICATIONS.titleId + override var nameRes = FbItem.NOTIFICATIONS.titleId - override val url = FbItem.NOTIFICATIONS.url + override val url = FbItem.NOTIFICATIONS.url - override fun parseImpl(doc: Document): FrostNotifs? { - val notificationList = doc.getElementById("notifications_list") ?: return null - val notifications = notificationList - .getElementsByAttributeValueMatching("id", ".*${FB_NOTIF_ID_MATCHER.pattern}.*") - .mapNotNull(this::parseNotif) - val seeMore = - parseLink(doc.getElementsByAttributeValue("href", "/notifications.php?more").first()) - return FrostNotifs(notifications, seeMore) - } + override fun parseImpl(doc: Document): FrostNotifs? { + val notificationList = doc.getElementById("notifications_list") ?: return null + val notifications = + notificationList + .getElementsByAttributeValueMatching("id", ".*${FB_NOTIF_ID_MATCHER.pattern}.*") + .mapNotNull(this::parseNotif) + val seeMore = + parseLink(doc.getElementsByAttributeValue("href", "/notifications.php?more").first()) + return FrostNotifs(notifications, seeMore) + } - private fun parseNotif(element: Element): FrostNotif? { - val a = element.getElementsByTag("a").first() ?: return null - a.selectFirst("span.accessible_elem")?.remove() - val abbr = element.getElementsByTag("abbr") - val epoch = FB_EPOCH_MATCHER.find(abbr.attr("data-store"))[1]?.toLongOrNull() ?: -1L - // fetch id - val id = FB_NOTIF_ID_MATCHER.find(element.id())[1]?.toLongOrNull() - ?: System.currentTimeMillis() % FALLBACK_TIME_MOD - val img = element.getInnerImgStyle() - val timeString = abbr.text() - val content = - a.text().replace("\u00a0", " ").removeSuffix(timeString).trim() // remove   - val thumbnail = element.selectFirst("img.thumbnail")?.attr("src") - return FrostNotif( - id = id, - img = img, - time = epoch, - url = a.attr("href").formattedFbUrl, - unread = !element.hasClass("acw"), - content = content, - timeString = timeString, - thumbnailUrl = if (thumbnail?.isNotEmpty() == true) thumbnail else null - ) - } + private fun parseNotif(element: Element): FrostNotif? { + val a = element.getElementsByTag("a").first() ?: return null + a.selectFirst("span.accessible_elem")?.remove() + val abbr = element.getElementsByTag("abbr") + val epoch = FB_EPOCH_MATCHER.find(abbr.attr("data-store"))[1]?.toLongOrNull() ?: -1L + // fetch id + val id = + FB_NOTIF_ID_MATCHER.find(element.id())[1]?.toLongOrNull() + ?: System.currentTimeMillis() % FALLBACK_TIME_MOD + val img = element.getInnerImgStyle() + val timeString = abbr.text() + val content = a.text().replace("\u00a0", " ").removeSuffix(timeString).trim() // remove   + val thumbnail = element.selectFirst("img.thumbnail")?.attr("src") + return FrostNotif( + id = id, + img = img, + time = epoch, + url = a.attr("href").formattedFbUrl, + unread = !element.hasClass("acw"), + content = content, + timeString = timeString, + thumbnailUrl = if (thumbnail?.isNotEmpty() == true) thumbnail else null + ) + } } diff --git a/app/src/main/kotlin/com/pitchedapps/frost/facebook/parsers/SearchParser.kt b/app/src/main/kotlin/com/pitchedapps/frost/facebook/parsers/SearchParser.kt index 4d45d4399..f5b9c8854 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/facebook/parsers/SearchParser.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/facebook/parsers/SearchParser.kt @@ -27,98 +27,91 @@ import com.pitchedapps.frost.utils.urlEncode import org.jsoup.nodes.Document import org.jsoup.nodes.Element -/** - * Created by Allan Wang on 2017-10-09. - */ +/** Created by Allan Wang on 2017-10-09. */ object SearchParser : FrostParser by SearchParserImpl() { - fun query(cookie: String?, input: String): ParseResponse? { - val url = - "${FbItem._SEARCH_PARSE.url}/?q=${if (input.isNotBlank()) input.urlEncode() else "a"}" - L._i { "Search Query $url" } - return parseFromUrl(cookie, url) - } + fun query(cookie: String?, input: String): ParseResponse? { + val url = "${FbItem._SEARCH_PARSE.url}/?q=${if (input.isNotBlank()) input.urlEncode() else "a"}" + L._i { "Search Query $url" } + return parseFromUrl(cookie, url) + } } enum class SearchKeys(val key: String) { - USERS("keywords_users"), - EVENTS("keywords_events") + USERS("keywords_users"), + EVENTS("keywords_events") } data class FrostSearches(val results: List) : ParseData { - override val isEmpty: Boolean - get() = results.isEmpty() + override val isEmpty: Boolean + get() = results.isEmpty() - override fun toString() = StringBuilder().apply { + override fun toString() = + StringBuilder() + .apply { append("FrostSearches {\n") append(results.toJsonString("results", 1)) append("}") - }.toString() + } + .toString() } /** - * As far as I'm aware, all links are independent, so the queries don't matter - * A lot of it is tracking information, which I'll strip away - * Other text items are formatted for safety + * As far as I'm aware, all links are independent, so the queries don't matter A lot of it is + * tracking information, which I'll strip away Other text items are formatted for safety * * Note that it's best to create search results from [create] */ data class FrostSearch(val href: String, val title: String, val description: String?) { - fun toSearchItem() = SearchItem(href, title, description) + fun toSearchItem() = SearchItem(href, title, description) - companion object { - fun create(href: String, title: String, description: String?) = FrostSearch( - with(href.indexOf("?")) { if (this == -1) href else href.substring(0, this) }, - title.format(), - description?.format() - ) - } + companion object { + fun create(href: String, title: String, description: String?) = + FrostSearch( + with(href.indexOf("?")) { if (this == -1) href else href.substring(0, this) }, + title.format(), + description?.format() + ) + } } private class SearchParserImpl : FrostParserBase(false) { - override var nameRes = FbItem._SEARCH_PARSE.titleId + override var nameRes = FbItem._SEARCH_PARSE.titleId - override val url = "${FbItem._SEARCH_PARSE.url}?q=google" + override val url = "${FbItem._SEARCH_PARSE.url}?q=google" - private val String.formattedSearchUrl: String - get() = replace(FACEBOOK_MBASIC_COM, FACEBOOK_BASE_COM) + private val String.formattedSearchUrl: String + get() = replace(FACEBOOK_MBASIC_COM, FACEBOOK_BASE_COM) - override fun parseImpl(doc: Document): FrostSearches? { - val container: Element = doc.getElementById("BrowseResultsContainer") - ?: doc.getElementById("root") - ?: return null + override fun parseImpl(doc: Document): FrostSearches? { + val container: Element = + doc.getElementById("BrowseResultsContainer") ?: doc.getElementById("root") ?: return null - return FrostSearches( - container.select("table[role=presentation]").mapNotNull { el -> - // Our assumption is that search entries start with an image, followed by general info - // There may be other s, but we will not be parsing them - // Furthermore, the entry wraps a link, containing all the necessary info - val a = el.select("td") - .getOrNull(1) - ?.selectFirst("a") - ?: return@mapNotNull null - val url = - a.attr("href").takeIf { it.isNotEmpty() } - ?.formattedFbUrl?.formattedSearchUrl - ?: return@mapNotNull null - // Currently, children should all be
elements, where the first entry is the name/title - // And the other entries are additional info. - // There are also cases of nested tables, eg for the "join" button in groups. - // Those elements have texts, so we will filter by div to ignore those - val texts = - a.children() - .filter { childEl: Element -> childEl.tagName() == "div" && childEl.hasText() } - val title = texts.firstOrNull()?.text() ?: return@mapNotNull null - val info = texts.takeIf { it.size > 1 }?.last()?.text() - L.e { a } - create( - href = url, - title = title, - description = info - ).also { L.e { it } } - } - ) - } + return FrostSearches( + container.select("table[role=presentation]").mapNotNull { el -> + // Our assumption is that search entries start with an image, followed by general info + // There may be other s, but we will not be parsing them + // Furthermore, the entry wraps a link, containing all the necessary info + val a = el.select("td").getOrNull(1)?.selectFirst("a") ?: return@mapNotNull null + val url = + a.attr("href").takeIf { it.isNotEmpty() }?.formattedFbUrl?.formattedSearchUrl + ?: return@mapNotNull null + // Currently, children should all be
elements, where the first entry is the + // name/title + // And the other entries are additional info. + // There are also cases of nested tables, eg for the "join" button in groups. + // Those elements have texts, so we will filter by div to ignore those + val texts = + a.children().filter { childEl: Element -> + childEl.tagName() == "div" && childEl.hasText() + } + val title = texts.firstOrNull()?.text() ?: return@mapNotNull null + val info = texts.takeIf { it.size > 1 }?.last()?.text() + L.e { a } + create(href = url, title = title, description = info).also { L.e { it } } + } + ) + } } diff --git a/app/src/main/kotlin/com/pitchedapps/frost/facebook/requests/FbRequest.kt b/app/src/main/kotlin/com/pitchedapps/frost/facebook/requests/FbRequest.kt index 7900534c7..fc86fea27 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/facebook/requests/FbRequest.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/facebook/requests/FbRequest.kt @@ -24,22 +24,17 @@ import okhttp3.Request import okhttp3.logging.HttpLoggingInterceptor val httpClient: OkHttpClient by lazy { - val builder = OkHttpClient.Builder() - if (BuildConfig.DEBUG) - builder.addInterceptor( - HttpLoggingInterceptor() - .setLevel(HttpLoggingInterceptor.Level.BASIC) - ) - builder.build() + val builder = OkHttpClient.Builder() + if (BuildConfig.DEBUG) + builder.addInterceptor(HttpLoggingInterceptor().setLevel(HttpLoggingInterceptor.Level.BASIC)) + builder.build() } internal fun String?.requestBuilder(): Request.Builder { - val builder = Request.Builder() - .header("User-Agent", USER_AGENT) - if (this != null) - builder.header("Cookie", this) -// .cacheControl(CacheControl.FORCE_NETWORK) - return builder + val builder = Request.Builder().header("User-Agent", USER_AGENT) + if (this != null) builder.header("Cookie", this) + // .cacheControl(CacheControl.FORCE_NETWORK) + return builder } fun Request.Builder.call(): Call = httpClient.newCall(build()) diff --git a/app/src/main/kotlin/com/pitchedapps/frost/facebook/requests/Images.kt b/app/src/main/kotlin/com/pitchedapps/frost/facebook/requests/Images.kt index 70f911a82..a97fb46ca 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/facebook/requests/Images.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/facebook/requests/Images.kt @@ -24,23 +24,19 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeout -/** - * Created by Allan Wang on 29/12/17. - */ +/** Created by Allan Wang on 29/12/17. */ -/** - * Attempts to get the fbcdn url of the supplied image redirect url - */ +/** Attempts to get the fbcdn url of the supplied image redirect url */ suspend fun String.getFullSizedImageUrl(url: String, timeout: Long = 3000): String? = - withContext(Dispatchers.IO) { - try { - withTimeout(timeout) { - val redirect = requestBuilder().url(url).get().call() - .execute().body?.string() ?: return@withTimeout null - FB_REDIRECT_URL_MATCHER.find(redirect)[1]?.formattedFbUrl - } - } catch (e: Exception) { - L.e(e) { "Failed to load full size image url" } - null - } + withContext(Dispatchers.IO) { + try { + withTimeout(timeout) { + val redirect = + requestBuilder().url(url).get().call().execute().body?.string() ?: return@withTimeout null + FB_REDIRECT_URL_MATCHER.find(redirect)[1]?.formattedFbUrl + } + } catch (e: Exception) { + L.e(e) { "Failed to load full size image url" } + null } + } diff --git a/app/src/main/kotlin/com/pitchedapps/frost/fragments/BaseFragment.kt b/app/src/main/kotlin/com/pitchedapps/frost/fragments/BaseFragment.kt index a3303638a..5e4ab5b79 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/fragments/BaseFragment.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/fragments/BaseFragment.kt @@ -43,211 +43,199 @@ import com.pitchedapps.frost.utils.REQUEST_REFRESH import com.pitchedapps.frost.utils.REQUEST_TEXT_ZOOM import com.pitchedapps.frost.utils.frostEvent import dagger.hilt.android.AndroidEntryPoint +import javax.inject.Inject +import kotlin.coroutines.CoroutineContext import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.isActive -import javax.inject.Inject -import kotlin.coroutines.CoroutineContext /** * Created by Allan Wang on 2017-11-07. * - * All fragments pertaining to the main view - * Must be attached to activities implementing [MainActivityContract] + * All fragments pertaining to the main view Must be attached to activities implementing + * [MainActivityContract] */ @AndroidEntryPoint -abstract class BaseFragment : - Fragment(), - CoroutineScope, - FragmentContract, - DynamicUiContract { +abstract class BaseFragment : Fragment(), CoroutineScope, FragmentContract, DynamicUiContract { - companion object { - private const val ARG_POSITION = "arg_position" - private const val ARG_VALID = "arg_valid" + companion object { + private const val ARG_POSITION = "arg_position" + private const val ARG_VALID = "arg_valid" - internal operator fun invoke( - base: () -> BaseFragment, - prefs: Prefs, - useFallback: Boolean, - data: FbItem, - position: Int - ): BaseFragment { - val fragment = if (useFallback) WebFragment() else base() - val d = if (data == FbItem.FEED) FeedSort(prefs.feedSort).item else data - fragment.withArguments( - ARG_URL to d.url, - ARG_POSITION to position - ) - d.put(fragment.requireArguments()) - return fragment - } + internal operator fun invoke( + base: () -> BaseFragment, + prefs: Prefs, + useFallback: Boolean, + data: FbItem, + position: Int + ): BaseFragment { + val fragment = if (useFallback) WebFragment() else base() + val d = if (data == FbItem.FEED) FeedSort(prefs.feedSort).item else data + fragment.withArguments(ARG_URL to d.url, ARG_POSITION to position) + d.put(fragment.requireArguments()) + return fragment + } + } + + @Inject protected lateinit var mainContract: MainActivityContract + + @Inject protected lateinit var fbCookie: FbCookie + + @Inject protected lateinit var prefs: Prefs + + @Inject protected lateinit var themeProvider: ThemeProvider + + open lateinit var job: Job + override val coroutineContext: CoroutineContext + get() = ContextHelper.dispatcher + job + + override val baseUrl: String by lazy { requireArguments().getString(ARG_URL)!! } + override val baseEnum: FbItem by lazy { FbItem[arguments]!! } + override val position: Int by lazy { requireArguments().getInt(ARG_POSITION) } + + override var valid: Boolean + get() = requireArguments().getBoolean(ARG_VALID, true) + set(value) { + if (!isActive || value || this is WebFragment) return + requireArguments().putBoolean(ARG_VALID, value) + frostEvent("Native Fallback", "Item" to baseEnum.name) + mainContract.reloadFragment(this) } - @Inject - protected lateinit var mainContract: MainActivityContract + override var firstLoad: Boolean = true + private var onCreateRunnable: ((FragmentContract) -> Unit)? = null - @Inject - protected lateinit var fbCookie: FbCookie + override var content: FrostContentParent? = null - @Inject - protected lateinit var prefs: Prefs + protected abstract val layoutRes: Int - @Inject - protected lateinit var themeProvider: ThemeProvider + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + job = SupervisorJob() + firstLoad = true + } - open lateinit var job: Job - override val coroutineContext: CoroutineContext - get() = ContextHelper.dispatcher + job + final override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View? { + val view = inflater.inflate(layoutRes, container, false) + val content = + view as? FrostContentParent + ?: throw IllegalArgumentException( + "layoutRes for fragment must return view implementing FrostContentParent" + ) + this.content = content + content.bind(this) + return view + } - override val baseUrl: String by lazy { requireArguments().getString(ARG_URL)!! } - override val baseEnum: FbItem by lazy { FbItem[arguments]!! } - override val position: Int by lazy { requireArguments().getInt(ARG_POSITION) } + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + onCreateRunnable?.invoke(this) + onCreateRunnable = null + firstLoadRequest() + attach(mainContract) + } - override var valid: Boolean - get() = requireArguments().getBoolean(ARG_VALID, true) - set(value) { - if (!isActive || value || this is WebFragment) return - requireArguments().putBoolean(ARG_VALID, value) - frostEvent( - "Native Fallback", - "Item" to baseEnum.name - ) - mainContract.reloadFragment(this) - } + override fun setUserVisibleHint(isVisibleToUser: Boolean) { + super.setUserVisibleHint(isVisibleToUser) + firstLoadRequest() + } - override var firstLoad: Boolean = true - private var onCreateRunnable: ((FragmentContract) -> Unit)? = null - - override var content: FrostContentParent? = null - - protected abstract val layoutRes: Int - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - job = SupervisorJob() - firstLoad = true + override fun firstLoadRequest() { + val core = core ?: return + if (userVisibleHint && isVisible && firstLoad) { + core.reloadBase(true) + firstLoad = false } + } - final override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View? { - val view = inflater.inflate(layoutRes, container, false) - val content = view as? FrostContentParent - ?: throw IllegalArgumentException("layoutRes for fragment must return view implementing FrostContentParent") - this.content = content - content.bind(this) - return view - } + override fun post(action: (fragment: FragmentContract) -> Unit) { + onCreateRunnable = action + } - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - onCreateRunnable?.invoke(this) - onCreateRunnable = null - firstLoadRequest() - attach(mainContract) - } + override fun setTitle(title: String) { + mainContract.setTitle(title) + } - override fun setUserVisibleHint(isVisibleToUser: Boolean) { - super.setUserVisibleHint(isVisibleToUser) - firstLoadRequest() - } - - override fun firstLoadRequest() { - val core = core ?: return - if (userVisibleHint && isVisible && firstLoad) { - core.reloadBase(true) - firstLoad = false - } - } - - override fun post(action: (fragment: FragmentContract) -> Unit) { - onCreateRunnable = action - } - - override fun setTitle(title: String) { - mainContract.setTitle(title) - } - - override fun attach(contract: MainActivityContract) { - contract.fragmentFlow - .flowWithLifecycle(viewLifecycleOwner.lifecycle) - .onEach { flag -> - when (flag) { - REQUEST_REFRESH -> { - core?.apply { - clearHistory() - firstLoad = true - firstLoadRequest() - } - } - position -> { - contract.setTitle(baseEnum.titleId) - updateFab(contract) - core?.active = true - } - -(position + 1) -> { - core?.active = false - } - REQUEST_TEXT_ZOOM -> { - reloadTextSize() - } - } - }.launchIn(this) - } - - override fun updateFab(contract: MainFabContract) { - contract.hideFab() // default - } - - protected fun FloatingActionButton.update(iicon: IIcon, click: () -> Unit) { - if (isShown) { - fadeScaleTransition { - setIcon(iicon, themeProvider.iconColor) + override fun attach(contract: MainActivityContract) { + contract.fragmentFlow + .flowWithLifecycle(viewLifecycleOwner.lifecycle) + .onEach { flag -> + when (flag) { + REQUEST_REFRESH -> { + core?.apply { + clearHistory() + firstLoad = true + firstLoadRequest() } - } else { - setIcon(iicon, themeProvider.iconColor) - show() + } + position -> { + contract.setTitle(baseEnum.titleId) + updateFab(contract) + core?.active = true + } + -(position + 1) -> { + core?.active = false + } + REQUEST_TEXT_ZOOM -> { + reloadTextSize() + } } - setOnClickListener { click() } + } + .launchIn(this) + } + + override fun updateFab(contract: MainFabContract) { + contract.hideFab() // default + } + + protected fun FloatingActionButton.update(iicon: IIcon, click: () -> Unit) { + if (isShown) { + fadeScaleTransition { setIcon(iicon, themeProvider.iconColor) } + } else { + setIcon(iicon, themeProvider.iconColor) + show() } + setOnClickListener { click() } + } - override fun onDestroyView() { - super.onDestroyView() - L.i { "Fragment on destroy $position ${hashCode()}" } - content?.destroy() - content = null - } + override fun onDestroyView() { + super.onDestroyView() + L.i { "Fragment on destroy $position ${hashCode()}" } + content?.destroy() + content = null + } - override fun onDestroy() { - job.cancel() - super.onDestroy() - } + override fun onDestroy() { + job.cancel() + super.onDestroy() + } - override fun reloadTheme() { - reloadThemeSelf() - content?.reloadTextSize() - } + override fun reloadTheme() { + reloadThemeSelf() + content?.reloadTextSize() + } - override fun reloadThemeSelf() { - // intentionally blank - } + override fun reloadThemeSelf() { + // intentionally blank + } - override fun reloadTextSize() { - reloadTextSizeSelf() - content?.reloadTextSize() - } + override fun reloadTextSize() { + reloadTextSizeSelf() + content?.reloadTextSize() + } - override fun reloadTextSizeSelf() { - // intentionally blank - } + override fun reloadTextSizeSelf() { + // intentionally blank + } - override fun onBackPressed(): Boolean = content?.core?.onBackPressed() ?: false + override fun onBackPressed(): Boolean = content?.core?.onBackPressed() ?: false - override fun onTabClick(): Unit = content?.core?.onTabClicked() ?: Unit + override fun onTabClick(): Unit = content?.core?.onTabClicked() ?: Unit } diff --git a/app/src/main/kotlin/com/pitchedapps/frost/fragments/FragmentContract.kt b/app/src/main/kotlin/com/pitchedapps/frost/fragments/FragmentContract.kt index beac7494b..4cbd25231 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/fragments/FragmentContract.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/fragments/FragmentContract.kt @@ -23,82 +23,65 @@ import com.pitchedapps.frost.contracts.MainActivityContract import com.pitchedapps.frost.contracts.MainFabContract import com.pitchedapps.frost.views.FrostRecyclerView -/** - * Created by Allan Wang on 2017-11-07. - */ - +/** Created by Allan Wang on 2017-11-07. */ interface FragmentContract : FrostContentContainer { - val content: FrostContentParent? + val content: FrostContentParent? - /** - * Defines whether the fragment is valid in the viewpager - * or if it needs to be recreated - * May be called from any thread to toggle status. - * Note that calls beyond the fragment lifecycle will be ignored - */ - var valid: Boolean + /** + * Defines whether the fragment is valid in the viewpager or if it needs to be recreated May be + * called from any thread to toggle status. Note that calls beyond the fragment lifecycle will be + * ignored + */ + var valid: Boolean - /** - * Helper to retrieve the core from [content] - */ - val core: FrostContentCore? - get() = content?.core + /** Helper to retrieve the core from [content] */ + val core: FrostContentCore? + get() = content?.core - /** - * Specifies position in Activity's viewpager - */ - val position: Int + /** Specifies position in Activity's viewpager */ + val position: Int - /** - * Specifies whether if current load - * will be fragment's first load - * - * Defaults to true - */ - var firstLoad: Boolean + /** + * Specifies whether if current load will be fragment's first load + * + * Defaults to true + */ + var firstLoad: Boolean - /** - * Called when the fragment is first visible - * Typically, if [firstLoad] is true, - * the fragment should call [reload] and make [firstLoad] false - */ - fun firstLoadRequest() + /** + * Called when the fragment is first visible Typically, if [firstLoad] is true, the fragment + * should call [reload] and make [firstLoad] false + */ + fun firstLoadRequest() - fun updateFab(contract: MainFabContract) + fun updateFab(contract: MainFabContract) - /** - * Single callable action to be executed upon creation - * Note that this call is not guaranteed - */ - fun post(action: (fragment: FragmentContract) -> Unit) + /** Single callable action to be executed upon creation Note that this call is not guaranteed */ + fun post(action: (fragment: FragmentContract) -> Unit) - /** - * Call whenever a fragment is attached so that it may listen - * to activity emissions. - */ - fun attach(contract: MainActivityContract) + /** Call whenever a fragment is attached so that it may listen to activity emissions. */ + fun attach(contract: MainActivityContract) - /* - * ----------------------------------------- - * Delegates - * ----------------------------------------- - */ + /* + * ----------------------------------------- + * Delegates + * ----------------------------------------- + */ - fun onBackPressed(): Boolean + fun onBackPressed(): Boolean - fun onTabClick() + fun onTabClick() } interface RecyclerContentContract { - fun bind(recyclerView: FrostRecyclerView) + fun bind(recyclerView: FrostRecyclerView) - /** - * Completely handle data reloading, within a non-ui thread - * The progress function allows optional emission of progress values (between 0 and 100) - * and can be called from any thread. - * Returns [true] for success, [false] otherwise - */ - suspend fun reload(progress: (Int) -> Unit): Boolean + /** + * Completely handle data reloading, within a non-ui thread The progress function allows optional + * emission of progress values (between 0 and 100) and can be called from any thread. Returns + * [true] for success, [false] otherwise + */ + suspend fun reload(progress: (Int) -> Unit): Boolean } diff --git a/app/src/main/kotlin/com/pitchedapps/frost/fragments/RecyclerFragmentBase.kt b/app/src/main/kotlin/com/pitchedapps/frost/fragments/RecyclerFragmentBase.kt index 51beab933..59eb93a40 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/fragments/RecyclerFragmentBase.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/fragments/RecyclerFragmentBase.kt @@ -32,119 +32,113 @@ import com.pitchedapps.frost.views.FrostRecyclerView import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext -/** - * Created by Allan Wang on 27/12/17. - */ +/** Created by Allan Wang on 27/12/17. */ abstract class RecyclerFragment : BaseFragment(), RecyclerContentContract { - override val layoutRes: Int = R.layout.view_content_recycler + override val layoutRes: Int = R.layout.view_content_recycler - abstract val adapter: ModelAdapter + abstract val adapter: ModelAdapter - override fun firstLoadRequest() { - val core = core ?: return - if (firstLoad) { - core.reloadBase(true) - firstLoad = false + override fun firstLoadRequest() { + val core = core ?: return + if (firstLoad) { + core.reloadBase(true) + firstLoad = false + } + } + + final override suspend fun reload(progress: (Int) -> Unit): Boolean = + withContext(Dispatchers.IO) { + val data = + try { + reloadImpl(progress) + } catch (e: Exception) { + L.e(e) { "Recycler reload fail $baseUrl" } + null } + withMainContext { + if (data == null) { + valid = false + false + } else { + adapter.setNewList(data) + true + } + } } - final override suspend fun reload(progress: (Int) -> Unit): Boolean = - withContext(Dispatchers.IO) { - val data = try { - reloadImpl(progress) - } catch (e: Exception) { - L.e(e) { "Recycler reload fail $baseUrl" } - null - } - withMainContext { - if (data == null) { - valid = false - false - } else { - adapter.setNewList(data) - true - } - } - } - - protected abstract suspend fun reloadImpl(progress: (Int) -> Unit): List? + protected abstract suspend fun reloadImpl(progress: (Int) -> Unit): List? } abstract class GenericRecyclerFragment : RecyclerFragment() { - abstract fun mapper(data: T): Item + abstract fun mapper(data: T): Item - override val adapter: ModelAdapter = ModelAdapter { this.mapper(it) } + override val adapter: ModelAdapter = ModelAdapter { this.mapper(it) } - final override fun bind(recyclerView: FrostRecyclerView) { - recyclerView.adapter = getAdapter() - recyclerView.onReloadClear = { adapter.clear() } - bindImpl(recyclerView) - } + final override fun bind(recyclerView: FrostRecyclerView) { + recyclerView.adapter = getAdapter() + recyclerView.onReloadClear = { adapter.clear() } + bindImpl(recyclerView) + } - /** - * Anything to call for one time bindings - * At this stage, all adapters will have FastAdapter references - */ - open fun bindImpl(recyclerView: FrostRecyclerView) = Unit + /** + * Anything to call for one time bindings At this stage, all adapters will have FastAdapter + * references + */ + open fun bindImpl(recyclerView: FrostRecyclerView) = Unit - /** - * Create the fast adapter to bind to the recyclerview - */ - open fun getAdapter(): GenericFastAdapter = fastAdapter(this.adapter) + /** Create the fast adapter to bind to the recyclerview */ + open fun getAdapter(): GenericFastAdapter = fastAdapter(this.adapter) } abstract class FrostParserFragment : - RecyclerFragment() { + RecyclerFragment() { - /** - * The parser to make this all happen - */ - abstract val parser: FrostParser + /** The parser to make this all happen */ + abstract val parser: FrostParser - open fun getDoc(cookie: String?) = frostJsoup(cookie, parser.url) + open fun getDoc(cookie: String?) = frostJsoup(cookie, parser.url) - abstract fun toItems(response: ParseResponse): List + abstract fun toItems(response: ParseResponse): List - override val adapter: ItemAdapter = ItemAdapter() + override val adapter: ItemAdapter = ItemAdapter() - final override fun bind(recyclerView: FrostRecyclerView) { - recyclerView.adapter = getAdapter() - recyclerView.onReloadClear = { adapter.clear() } - bindImpl(recyclerView) - } + final override fun bind(recyclerView: FrostRecyclerView) { + recyclerView.adapter = getAdapter() + recyclerView.onReloadClear = { adapter.clear() } + bindImpl(recyclerView) + } - /** - * Anything to call for one time bindings - * At this stage, all adapters will have FastAdapter references - */ - open fun bindImpl(recyclerView: FrostRecyclerView) = Unit + /** + * Anything to call for one time bindings At this stage, all adapters will have FastAdapter + * references + */ + open fun bindImpl(recyclerView: FrostRecyclerView) = Unit - /** - * Create the fast adapter to bind to the recyclerview - */ - open fun getAdapter(): GenericFastAdapter = fastAdapter(this.adapter) + /** Create the fast adapter to bind to the recyclerview */ + open fun getAdapter(): GenericFastAdapter = fastAdapter(this.adapter) - override suspend fun reloadImpl(progress: (Int) -> Unit): List? = - withContext(Dispatchers.IO) { - progress(10) - val cookie = fbCookie.webCookie - val doc = getDoc(cookie) - progress(60) - val response = try { - parser.parse(cookie, doc) - } catch (ignored: Exception) { - null - } - if (response == null) { - L.i { "RecyclerFragment failed for ${baseEnum.name}" } - L._d { "Cookie used: $cookie" } - return@withContext null - } - progress(80) - val items = toItems(response) - progress(97) - return@withContext items + override suspend fun reloadImpl(progress: (Int) -> Unit): List? = + withContext(Dispatchers.IO) { + progress(10) + val cookie = fbCookie.webCookie + val doc = getDoc(cookie) + progress(60) + val response = + try { + parser.parse(cookie, doc) + } catch (ignored: Exception) { + null } + if (response == null) { + L.i { "RecyclerFragment failed for ${baseEnum.name}" } + L._d { "Cookie used: $cookie" } + return@withContext null + } + progress(80) + val items = toItems(response) + progress(97) + return@withContext items + } } diff --git a/app/src/main/kotlin/com/pitchedapps/frost/fragments/RecyclerFragments.kt b/app/src/main/kotlin/com/pitchedapps/frost/fragments/RecyclerFragments.kt index 28962230d..5b2f4b594 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/fragments/RecyclerFragments.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/fragments/RecyclerFragments.kt @@ -27,19 +27,22 @@ import com.pitchedapps.frost.views.FrostRecyclerView /** * Created by Allan Wang on 27/12/17. * - * Retained as an example. Deletion made at https://github.com/AllanWang/Frost-for-Facebook/pull/1542 + * Retained as an example. Deletion made at + * https://github.com/AllanWang/Frost-for-Facebook/pull/1542 */ -@Deprecated(message = "Retained as an example; currently does not support marking a notification as read") +@Deprecated( + message = "Retained as an example; currently does not support marking a notification as read" +) class NotificationFragment : FrostParserFragment() { - override val parser = NotifParser + override val parser = NotifParser - override fun getDoc(cookie: String?) = frostJsoup(cookie, "${FbItem.NOTIFICATIONS.url}?more") + override fun getDoc(cookie: String?) = frostJsoup(cookie, "${FbItem.NOTIFICATIONS.url}?more") - override fun toItems(response: ParseResponse): List = - response.data.notifs.map { NotificationIItem(it, response.cookie, themeProvider) } + override fun toItems(response: ParseResponse): List = + response.data.notifs.map { NotificationIItem(it, response.cookie, themeProvider) } - override fun bindImpl(recyclerView: FrostRecyclerView) { - NotificationIItem.bindEvents(adapter, fbCookie, prefs, themeProvider) - } + override fun bindImpl(recyclerView: FrostRecyclerView) { + NotificationIItem.bindEvents(adapter, fbCookie, prefs, themeProvider) + } } diff --git a/app/src/main/kotlin/com/pitchedapps/frost/fragments/WebFragments.kt b/app/src/main/kotlin/com/pitchedapps/frost/fragments/WebFragments.kt index 29473461f..1981fea87 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/fragments/WebFragments.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/fragments/WebFragments.kt @@ -31,34 +31,30 @@ import com.pitchedapps.frost.web.FrostWebViewClientMessenger /** * Created by Allan Wang on 27/12/17. * - * Basic webfragment - * Do not extend as this is always a fallback + * Basic webfragment Do not extend as this is always a fallback */ class WebFragment : BaseFragment() { - override val layoutRes: Int = R.layout.view_content_web + override val layoutRes: Int = R.layout.view_content_web - /** - * Given a webview, output a client - */ - fun client(web: FrostWebView) = when (baseEnum) { - FbItem.MESSENGER -> FrostWebViewClientMessenger(web) - FbItem.MENU -> FrostWebViewClientMenu(web) - else -> FrostWebViewClient(web) + /** Given a webview, output a client */ + fun client(web: FrostWebView) = + when (baseEnum) { + FbItem.MESSENGER -> FrostWebViewClientMessenger(web) + FbItem.MENU -> FrostWebViewClientMenu(web) + else -> FrostWebViewClient(web) } - override fun updateFab(contract: MainFabContract) { - val web = core as? WebView - if (web == null) { - L.e { "Webview not found in fragment $baseEnum" } - return super.updateFab(contract) - } - if (baseEnum.isFeed && prefs.showCreateFab) { - contract.showFab(GoogleMaterial.Icon.gmd_edit) { - JsActions.CREATE_POST.inject(web, prefs) - } - return - } - super.updateFab(contract) + override fun updateFab(contract: MainFabContract) { + val web = core as? WebView + if (web == null) { + L.e { "Webview not found in fragment $baseEnum" } + return super.updateFab(contract) } + if (baseEnum.isFeed && prefs.showCreateFab) { + contract.showFab(GoogleMaterial.Icon.gmd_edit) { JsActions.CREATE_POST.inject(web, prefs) } + return + } + super.updateFab(contract) + } } diff --git a/app/src/main/kotlin/com/pitchedapps/frost/glide/GlideUtils.kt b/app/src/main/kotlin/com/pitchedapps/frost/glide/GlideUtils.kt index 5600d49de..98c7536d4 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/glide/GlideUtils.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/glide/GlideUtils.kt @@ -27,50 +27,51 @@ import com.bumptech.glide.load.resource.bitmap.CircleCrop import com.bumptech.glide.module.AppGlideModule import com.bumptech.glide.request.RequestOptions import com.pitchedapps.frost.facebook.FbCookie +import javax.inject.Inject import okhttp3.Interceptor import okhttp3.Response -import javax.inject.Inject /** * Created by Allan Wang on 28/12/17. * - * Collection of transformations - * Each caller will generate a new one upon request + * Collection of transformations Each caller will generate a new one upon request */ object FrostGlide { - val circleCrop - get() = CircleCrop() + val circleCrop + get() = CircleCrop() } -fun RequestBuilder.transform(vararg transformation: BitmapTransformation): RequestBuilder = - when (transformation.size) { - 0 -> this - 1 -> apply(RequestOptions.bitmapTransform(transformation[0])) - else -> apply(RequestOptions.bitmapTransform(MultiTransformation(*transformation))) - } +fun RequestBuilder.transform( + vararg transformation: BitmapTransformation +): RequestBuilder = + when (transformation.size) { + 0 -> this + 1 -> apply(RequestOptions.bitmapTransform(transformation[0])) + else -> apply(RequestOptions.bitmapTransform(MultiTransformation(*transformation))) + } @GlideModule class FrostGlideModule : AppGlideModule() { - override fun registerComponents(context: Context, glide: Glide, registry: Registry) { -// registry.replace(GlideUrl::class.java, -// InputStream::class.java, -// OkHttpUrlLoader.Factory(getFrostHttpClient())) -// registry.prepend(HdImageMaybe::class.java, InputStream::class.java, HdImageLoadingFactory()) - } + override fun registerComponents(context: Context, glide: Glide, registry: Registry) { + // registry.replace(GlideUrl::class.java, + // InputStream::class.java, + // OkHttpUrlLoader.Factory(getFrostHttpClient())) + // registry.prepend(HdImageMaybe::class.java, InputStream::class.java, + // HdImageLoadingFactory()) + } } // private fun getFrostHttpClient(): OkHttpClient = // OkHttpClient.Builder().addInterceptor(FrostCookieInterceptor()).build() -class FrostCookieInterceptor @Inject internal constructor( - private val fbCookie: FbCookie -) : Interceptor { +class FrostCookieInterceptor @Inject internal constructor(private val fbCookie: FbCookie) : + Interceptor { - override fun intercept(chain: Interceptor.Chain): Response { - val origRequest = chain.request() - val cookie = fbCookie.webCookie ?: return chain.proceed(origRequest) - val request = origRequest.newBuilder().addHeader("Cookie", cookie).build() - return chain.proceed(request) - } + override fun intercept(chain: Interceptor.Chain): Response { + val origRequest = chain.request() + val cookie = fbCookie.webCookie ?: return chain.proceed(origRequest) + val request = origRequest.newBuilder().addHeader("Cookie", cookie).build() + return chain.proceed(request) + } } diff --git a/app/src/main/kotlin/com/pitchedapps/frost/iitems/GenericIItems.kt b/app/src/main/kotlin/com/pitchedapps/frost/iitems/GenericIItems.kt index 27263789c..8ed78b98f 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/iitems/GenericIItems.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/iitems/GenericIItems.kt @@ -32,101 +32,86 @@ import com.pitchedapps.frost.injectors.ThemeProvider import com.pitchedapps.frost.prefs.Prefs import com.pitchedapps.frost.utils.launchWebOverlay -/** - * Created by Allan Wang on 30/12/17. - */ +/** Created by Allan Wang on 30/12/17. */ -/** - * Base contract for anything with a url that may be launched in a new overlay - */ +/** Base contract for anything with a url that may be launched in a new overlay */ interface ClickableIItemContract { - val url: String? + val url: String? - fun click(context: Context, fbCookie: FbCookie, prefs: Prefs) { - val url = url ?: return - context.launchWebOverlay(url, fbCookie, prefs) - } + fun click(context: Context, fbCookie: FbCookie, prefs: Prefs) { + val url = url ?: return + context.launchWebOverlay(url, fbCookie, prefs) + } - companion object { - fun bindEvents(adapter: IAdapter, fbCookie: FbCookie, prefs: Prefs) { - adapter.fastAdapter?.apply { - selectExtension { - isSelectable = false - } - onClickListener = { v, _, item, _ -> - if (item is ClickableIItemContract) { - item.click(v!!.context, fbCookie, prefs) - true - } else - false - } - } + companion object { + fun bindEvents(adapter: IAdapter, fbCookie: FbCookie, prefs: Prefs) { + adapter.fastAdapter?.apply { + selectExtension { isSelectable = false } + onClickListener = { v, _, item, _ -> + if (item is ClickableIItemContract) { + item.click(v!!.context, fbCookie, prefs) + true + } else false } + } } + } } -/** - * Generic header item - * Not clickable with an accent color - */ +/** Generic header item Not clickable with an accent color */ open class HeaderIItem( - val text: String?, - itemId: Int = R.layout.iitem_header, - private val themeProvider: ThemeProvider -) : KauIItem( + val text: String?, + itemId: Int = R.layout.iitem_header, + private val themeProvider: ThemeProvider +) : + KauIItem( R.layout.iitem_header, { ViewHolder(it, themeProvider) }, itemId -) { + ) { - class ViewHolder( - itemView: View, - private val themeProvider: ThemeProvider - ) : FastAdapter.ViewHolder(itemView) { + class ViewHolder(itemView: View, private val themeProvider: ThemeProvider) : + FastAdapter.ViewHolder(itemView) { - val text: TextView by bindView(R.id.item_header_text) + val text: TextView by bindView(R.id.item_header_text) - override fun bindView(item: HeaderIItem, payloads: List) { - text.setTextColor(themeProvider.accentColor) - text.text = item.text - text.setBackgroundColor(themeProvider.nativeBgColor) - } - - override fun unbindView(item: HeaderIItem) { - text.text = null - } + override fun bindView(item: HeaderIItem, payloads: List) { + text.setTextColor(themeProvider.accentColor) + text.text = item.text + text.setBackgroundColor(themeProvider.nativeBgColor) } + + override fun unbindView(item: HeaderIItem) { + text.text = null + } + } } -/** - * Generic text item - * Clickable with text color - */ +/** Generic text item Clickable with text color */ open class TextIItem( - val text: String?, - override val url: String?, - itemId: Int = R.layout.iitem_text, - private val themeProvider: ThemeProvider -) : KauIItem(R.layout.iitem_text, { ViewHolder(it, themeProvider) }, itemId), - ClickableIItemContract { + val text: String?, + override val url: String?, + itemId: Int = R.layout.iitem_text, + private val themeProvider: ThemeProvider +) : + KauIItem(R.layout.iitem_text, { ViewHolder(it, themeProvider) }, itemId), + ClickableIItemContract { - class ViewHolder( - itemView: View, - private val themeProvider: ThemeProvider - ) : FastAdapter.ViewHolder(itemView) { + class ViewHolder(itemView: View, private val themeProvider: ThemeProvider) : + FastAdapter.ViewHolder(itemView) { - val text: TextView by bindView(R.id.item_text_view) + val text: TextView by bindView(R.id.item_text_view) - override fun bindView(item: TextIItem, payloads: List) { - text.setTextColor(themeProvider.textColor) - text.text = item.text - text.background = - createSimpleRippleDrawable(themeProvider.bgColor, themeProvider.nativeBgColor) - } - - override fun unbindView(item: TextIItem) { - text.text = null - } + override fun bindView(item: TextIItem, payloads: List) { + text.setTextColor(themeProvider.textColor) + text.text = item.text + text.background = + createSimpleRippleDrawable(themeProvider.bgColor, themeProvider.nativeBgColor) } + + override fun unbindView(item: TextIItem) { + text.text = null + } + } } diff --git a/app/src/main/kotlin/com/pitchedapps/frost/iitems/NotificationIItem.kt b/app/src/main/kotlin/com/pitchedapps/frost/iitems/NotificationIItem.kt index f0fb1a280..82f5e86e2 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/iitems/NotificationIItem.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/iitems/NotificationIItem.kt @@ -41,118 +41,107 @@ import com.pitchedapps.frost.prefs.Prefs import com.pitchedapps.frost.utils.isIndependent import com.pitchedapps.frost.utils.launchWebOverlay -/** - * Created by Allan Wang on 27/12/17. - */ +/** Created by Allan Wang on 27/12/17. */ class NotificationIItem( - val notification: FrostNotif, - val cookie: String, - private val themeProvider: ThemeProvider -) : KauIItem( - R.layout.iitem_notification, { ViewHolder(it, themeProvider) } -) { + val notification: FrostNotif, + val cookie: String, + private val themeProvider: ThemeProvider +) : + KauIItem( + R.layout.iitem_notification, + { ViewHolder(it, themeProvider) } + ) { - companion object { - fun bindEvents( - adapter: ItemAdapter, - fbCookie: FbCookie, - prefs: Prefs, - themeProvider: ThemeProvider - ) { - adapter.fastAdapter?.apply { - selectExtension { - isSelectable = false - } - onClickListener = { v, _, item, position -> - val notif = item.notification - if (notif.unread) { - adapter.set( - position, - NotificationIItem( - notif.copy(unread = false), - item.cookie, - themeProvider - ) - ) - } - // TODO temp fix. If url is dependent, we cannot load it directly - v!!.context.launchWebOverlay( - if (notif.url.isIndependent) notif.url else FbItem.NOTIFICATIONS.url, - fbCookie, - prefs - ) - true - } - } - } - - // todo see if necessary - val DIFF: DiffCallback by lazy(::Diff) - } - - private class Diff : DiffCallback { - - override fun areItemsTheSame(oldItem: NotificationIItem, newItem: NotificationIItem) = - oldItem.notification.id == newItem.notification.id - - override fun areContentsTheSame( - oldItem: NotificationIItem, - newItem: NotificationIItem - ) = - oldItem.notification == newItem.notification - - override fun getChangePayload( - oldItem: NotificationIItem, - oldItemPosition: Int, - newItem: NotificationIItem, - newItemPosition: Int - ): Any? { - return newItem - } - } - - class ViewHolder( - itemView: View, - private val themeProvider: ThemeProvider - ) : FastAdapter.ViewHolder(itemView) { - - private val frame: ViewGroup by bindView(R.id.item_frame) - private val avatar: ImageView by bindView(R.id.item_avatar) - private val content: TextView by bindView(R.id.item_content) - private val date: TextView by bindView(R.id.item_date) - private val thumbnail: ImageView by bindView(R.id.item_thumbnail) - - private val glide - get() = GlideApp.with(itemView) - - override fun bindView(item: NotificationIItem, payloads: List) { - val notif = item.notification - frame.background = createSimpleRippleDrawable( - themeProvider.textColor, - themeProvider.nativeBgColor(notif.unread) + companion object { + fun bindEvents( + adapter: ItemAdapter, + fbCookie: FbCookie, + prefs: Prefs, + themeProvider: ThemeProvider + ) { + adapter.fastAdapter?.apply { + selectExtension { isSelectable = false } + onClickListener = { v, _, item, position -> + val notif = item.notification + if (notif.unread) { + adapter.set( + position, + NotificationIItem(notif.copy(unread = false), item.cookie, themeProvider) ) - content.setTextColor(themeProvider.textColor) - date.setTextColor(themeProvider.textColor.withAlpha(150)) - - val glide = glide - glide.load(notif.img) - .transform(FrostGlide.circleCrop) - .into(avatar) - if (notif.thumbnailUrl != null) - glide.load(notif.thumbnailUrl).into(thumbnail.visible()) - - content.text = notif.content - date.text = notif.timeString - } - - override fun unbindView(item: NotificationIItem) { - frame.background = null - val glide = glide - glide.clear(avatar) - glide.clear(thumbnail) - thumbnail.gone() - content.text = null - date.text = null + } + // TODO temp fix. If url is dependent, we cannot load it directly + v!! + .context + .launchWebOverlay( + if (notif.url.isIndependent) notif.url else FbItem.NOTIFICATIONS.url, + fbCookie, + prefs + ) + true } + } } + + // todo see if necessary + val DIFF: DiffCallback by lazy(::Diff) + } + + private class Diff : DiffCallback { + + override fun areItemsTheSame(oldItem: NotificationIItem, newItem: NotificationIItem) = + oldItem.notification.id == newItem.notification.id + + override fun areContentsTheSame(oldItem: NotificationIItem, newItem: NotificationIItem) = + oldItem.notification == newItem.notification + + override fun getChangePayload( + oldItem: NotificationIItem, + oldItemPosition: Int, + newItem: NotificationIItem, + newItemPosition: Int + ): Any? { + return newItem + } + } + + class ViewHolder(itemView: View, private val themeProvider: ThemeProvider) : + FastAdapter.ViewHolder(itemView) { + + private val frame: ViewGroup by bindView(R.id.item_frame) + private val avatar: ImageView by bindView(R.id.item_avatar) + private val content: TextView by bindView(R.id.item_content) + private val date: TextView by bindView(R.id.item_date) + private val thumbnail: ImageView by bindView(R.id.item_thumbnail) + + private val glide + get() = GlideApp.with(itemView) + + override fun bindView(item: NotificationIItem, payloads: List) { + val notif = item.notification + frame.background = + createSimpleRippleDrawable( + themeProvider.textColor, + themeProvider.nativeBgColor(notif.unread) + ) + content.setTextColor(themeProvider.textColor) + date.setTextColor(themeProvider.textColor.withAlpha(150)) + + val glide = glide + glide.load(notif.img).transform(FrostGlide.circleCrop).into(avatar) + if (notif.thumbnailUrl != null) glide.load(notif.thumbnailUrl).into(thumbnail.visible()) + + content.text = notif.content + date.text = notif.timeString + } + + override fun unbindView(item: NotificationIItem) { + frame.background = null + val glide = glide + glide.clear(avatar) + glide.clear(thumbnail) + thumbnail.gone() + content.text = null + date.text = null + } + } } diff --git a/app/src/main/kotlin/com/pitchedapps/frost/iitems/TabIItem.kt b/app/src/main/kotlin/com/pitchedapps/frost/iitems/TabIItem.kt index f9f9064b5..63f183036 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/iitems/TabIItem.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/iitems/TabIItem.kt @@ -31,41 +31,33 @@ import com.pitchedapps.frost.R import com.pitchedapps.frost.facebook.FbItem import com.pitchedapps.frost.injectors.ThemeProvider -/** - * Created by Allan Wang on 26/11/17. - */ +/** Created by Allan Wang on 26/11/17. */ class TabIItem(val item: FbItem, private val themeProvider: ThemeProvider) : - KauIItem( - R.layout.iitem_tab_preview, - { ViewHolder(it, themeProvider) } - ), - IDraggable { + KauIItem(R.layout.iitem_tab_preview, { ViewHolder(it, themeProvider) }), + IDraggable { - override val isDraggable: Boolean = true + override val isDraggable: Boolean = true - class ViewHolder( - itemView: View, - private val themeProvider: ThemeProvider - ) : FastAdapter.ViewHolder(itemView) { + class ViewHolder(itemView: View, private val themeProvider: ThemeProvider) : + FastAdapter.ViewHolder(itemView) { - val image: ImageView by bindView(R.id.image) - val text: TextView by bindView(R.id.text) + val image: ImageView by bindView(R.id.image) + val text: TextView by bindView(R.id.text) - override fun bindView(item: TabIItem, payloads: List) { - val isInToolbar = adapterPosition < 4 - val color = if (isInToolbar) themeProvider.iconColor else themeProvider.textColor - image.setIcon(item.item.icon, 20, color) - if (isInToolbar) - text.invisible() - else { - text.visible().setText(item.item.titleId) - text.setTextColor(color.withAlpha(200)) - } - } - - override fun unbindView(item: TabIItem) { - image.setImageDrawable(null) - text.visible().text = null - } + override fun bindView(item: TabIItem, payloads: List) { + val isInToolbar = adapterPosition < 4 + val color = if (isInToolbar) themeProvider.iconColor else themeProvider.textColor + image.setIcon(item.item.icon, 20, color) + if (isInToolbar) text.invisible() + else { + text.visible().setText(item.item.titleId) + text.setTextColor(color.withAlpha(200)) + } } + + override fun unbindView(item: TabIItem) { + image.setImageDrawable(null) + text.visible().text = null + } + } } diff --git a/app/src/main/kotlin/com/pitchedapps/frost/injectors/CssAsset.kt b/app/src/main/kotlin/com/pitchedapps/frost/injectors/CssAsset.kt index 39e2332a8..e4f06c12d 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/injectors/CssAsset.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/injectors/CssAsset.kt @@ -19,23 +19,24 @@ package com.pitchedapps.frost.injectors import android.webkit.WebView import com.pitchedapps.frost.prefs.Prefs -/** - * Small misc inline css assets - */ +/** Small misc inline css assets */ enum class CssAsset(private val content: String) : InjectorContract { - FullSizeImage("div._4prr[style*=\"max-width\"][style*=\"max-height\"]{max-width:none !important;max-height:none !important}"), + FullSizeImage( + "div._4prr[style*=\"max-width\"][style*=\"max-height\"]{max-width:none !important;max-height:none !important}" + ), - /* - * Remove top margin and hide some contents from the top bar and home page (as it's our base url) - */ - Menu("#bookmarks_flyout{margin-top:0 !important}#m_news_feed_stream,#MComposer{display:none !important}") - ; + /* + * Remove top margin and hide some contents from the top bar and home page (as it's our base url) + */ + Menu( + "#bookmarks_flyout{margin-top:0 !important}#m_news_feed_stream,#MComposer{display:none !important}" + ); - val injector: JsInjector by lazy { - JsBuilder().css(content).single("css-small-assets-$name").build() - } + val injector: JsInjector by lazy { + JsBuilder().css(content).single("css-small-assets-$name").build() + } - override fun inject(webView: WebView, prefs: Prefs) { - injector.inject(webView, prefs) - } + override fun inject(webView: WebView, prefs: Prefs) { + injector.inject(webView, prefs) + } } diff --git a/app/src/main/kotlin/com/pitchedapps/frost/injectors/CssHider.kt b/app/src/main/kotlin/com/pitchedapps/frost/injectors/CssHider.kt index 7b400a43d..20dd4127d 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/injectors/CssHider.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/injectors/CssHider.kt @@ -25,41 +25,34 @@ import com.pitchedapps.frost.prefs.Prefs * List of elements to hide */ enum class CssHider(private vararg val items: String) : InjectorContract { - CORE("[data-sigil=m_login_upsell]", "[role=progressbar]"), - HEADER( - "#header:not(.mFuturePageHeader):not(.titled)", - "#mJewelNav", - "[data-sigil=MTopBlueBarHeader]", - "#header-notices", - "[data-sigil*=m-promo-jewel-header]" - ), - ADS( - "article[data-xt*=sponsor]", - "article[data-store*=sponsor]" - ), - PEOPLE_YOU_MAY_KNOW("article._d2r"), - SUGGESTED_GROUPS("article[data-ft*=\"ei\":]"), - COMPOSER("#MComposer"), - MESSENGER("._s15", "[data-testid=info_panel]", "js_i"), - NON_RECENT("article:not([data-store*=actor_name])"), - STORIES( - "#MStoriesTray", - // Sub element with just the tray; title is not a part of this - "[data-testid=story_tray]" - ), - POST_ACTIONS( - "footer [data-sigil=\"ufi-inline-actions\"]" - ), - POST_REACTIONS( - "footer [data-sigil=\"reactions-bling-bar\"]" - ) - ; + CORE("[data-sigil=m_login_upsell]", "[role=progressbar]"), + HEADER( + "#header:not(.mFuturePageHeader):not(.titled)", + "#mJewelNav", + "[data-sigil=MTopBlueBarHeader]", + "#header-notices", + "[data-sigil*=m-promo-jewel-header]" + ), + ADS("article[data-xt*=sponsor]", "article[data-store*=sponsor]"), + PEOPLE_YOU_MAY_KNOW("article._d2r"), + SUGGESTED_GROUPS("article[data-ft*=\"ei\":]"), + COMPOSER("#MComposer"), + MESSENGER("._s15", "[data-testid=info_panel]", "js_i"), + NON_RECENT("article:not([data-store*=actor_name])"), + STORIES( + "#MStoriesTray", + // Sub element with just the tray; title is not a part of this + "[data-testid=story_tray]" + ), + POST_ACTIONS("footer [data-sigil=\"ufi-inline-actions\"]"), + POST_REACTIONS("footer [data-sigil=\"reactions-bling-bar\"]"); - val injector: JsInjector by lazy { - JsBuilder().css("${items.joinToString(separator = ",")}{display:none !important}") - .single("css-hider-$name").build() - } + val injector: JsInjector by lazy { + JsBuilder() + .css("${items.joinToString(separator = ",")}{display:none !important}") + .single("css-hider-$name") + .build() + } - override fun inject(webView: WebView, prefs: Prefs) = - injector.inject(webView, prefs) + override fun inject(webView: WebView, prefs: Prefs) = injector.inject(webView, prefs) } diff --git a/app/src/main/kotlin/com/pitchedapps/frost/injectors/JsActions.kt b/app/src/main/kotlin/com/pitchedapps/frost/injectors/JsActions.kt index 816bb3d6e..ebb449835 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/injectors/JsActions.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/injectors/JsActions.kt @@ -26,27 +26,26 @@ import com.pitchedapps.frost.prefs.Prefs * Collection of short js functions that are embedded directly */ enum class JsActions(body: String) : InjectorContract { - /** - * Redirects to login activity if create account is found - * see [com.pitchedapps.frost.web.FrostJSI.loadLogin] - */ - LOGIN_CHECK("document.getElementById('signup-button')&&Frost.loadLogin();"), - BASE_HREF("""document.write("");"""), - FETCH_BODY("""setTimeout(function(){var e=document.querySelector("main");e||(e=document.querySelector("body")),Frost.handleHtml(e.outerHTML)},1e2);"""), - RETURN_BODY("return(document.getElementsByTagName('html')[0].innerHTML);"), - CREATE_POST(clickBySelector("#MComposer [onclick]")), -// CREATE_MSG(clickBySelector("a[rel=dialog]")), - /** - * Used as a pseudoinjector for maybe functions - */ - EMPTY(""); + /** + * Redirects to login activity if create account is found see + * [com.pitchedapps.frost.web.FrostJSI.loadLogin] + */ + LOGIN_CHECK("document.getElementById('signup-button')&&Frost.loadLogin();"), + BASE_HREF("""document.write("");"""), + FETCH_BODY( + """setTimeout(function(){var e=document.querySelector("main");e||(e=document.querySelector("body")),Frost.handleHtml(e.outerHTML)},1e2);""" + ), + RETURN_BODY("return(document.getElementsByTagName('html')[0].innerHTML);"), + CREATE_POST(clickBySelector("#MComposer [onclick]")), + // CREATE_MSG(clickBySelector("a[rel=dialog]")), + /** Used as a pseudoinjector for maybe functions */ + EMPTY(""); - val function = "(function(){$body})();" + val function = "(function(){$body})();" - override fun inject(webView: WebView, prefs: Prefs) = - JsInjector(function).inject(webView, prefs) + override fun inject(webView: WebView, prefs: Prefs) = JsInjector(function).inject(webView, prefs) } @Suppress("NOTHING_TO_INLINE") private inline fun clickBySelector(selector: String): String = - """document.querySelector("$selector").click()""" + """document.querySelector("$selector").click()""" diff --git a/app/src/main/kotlin/com/pitchedapps/frost/injectors/JsAssets.kt b/app/src/main/kotlin/com/pitchedapps/frost/injectors/JsAssets.kt index f1c958bcf..c7f3caa10 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/injectors/JsAssets.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/injectors/JsAssets.kt @@ -22,43 +22,49 @@ import androidx.annotation.VisibleForTesting import ca.allanwang.kau.kotlin.lazyContext import com.pitchedapps.frost.prefs.Prefs import com.pitchedapps.frost.utils.L -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext import java.io.BufferedReader import java.io.FileNotFoundException import java.util.Locale +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext /** - * Created by Allan Wang on 2017-05-31. - * Mapping of the available assets - * The enum name must match the css file name + * Created by Allan Wang on 2017-05-31. Mapping of the available assets The enum name must match the + * css file name */ enum class JsAssets(private val singleLoad: Boolean = true) : InjectorContract { - MENU, MENU_QUICK(singleLoad = false), CLICK_A, CONTEXT_A, MEDIA, HEADER_BADGES, TEXTAREA_LISTENER, NOTIF_MSG, - DOCUMENT_WATCHER, HORIZONTAL_SCROLLING, AUTO_RESIZE_TEXTAREA(singleLoad = false), SCROLL_STOP, - ; + MENU, + MENU_QUICK(singleLoad = false), + CLICK_A, + CONTEXT_A, + MEDIA, + HEADER_BADGES, + TEXTAREA_LISTENER, + NOTIF_MSG, + DOCUMENT_WATCHER, + HORIZONTAL_SCROLLING, + AUTO_RESIZE_TEXTAREA(singleLoad = false), + SCROLL_STOP, + ; - @VisibleForTesting - internal val file = "${name.toLowerCase(Locale.CANADA)}.js" - private val injector = lazyContext { - try { - val content = it.assets.open("js/$file").bufferedReader().use(BufferedReader::readText) - JsBuilder().js(content).run { if (singleLoad) single(name) else this }.build() - } catch (e: FileNotFoundException) { - L.e(e) { "JsAssets file not found" } - JsInjector(JsActions.EMPTY.function) - } + @VisibleForTesting internal val file = "${name.toLowerCase(Locale.CANADA)}.js" + private val injector = lazyContext { + try { + val content = it.assets.open("js/$file").bufferedReader().use(BufferedReader::readText) + JsBuilder().js(content).run { if (singleLoad) single(name) else this }.build() + } catch (e: FileNotFoundException) { + L.e(e) { "JsAssets file not found" } + JsInjector(JsActions.EMPTY.function) } + } - override fun inject(webView: WebView, prefs: Prefs) = - injector(webView.context).inject(webView, prefs) + override fun inject(webView: WebView, prefs: Prefs) = + injector(webView.context).inject(webView, prefs) - companion object { - // Ensures that all non themes and the selected theme are loaded - suspend fun load(context: Context) { - withContext(Dispatchers.IO) { - values().forEach { it.injector.invoke(context) } - } - } + companion object { + // Ensures that all non themes and the selected theme are loaded + suspend fun load(context: Context) { + withContext(Dispatchers.IO) { values().forEach { it.injector.invoke(context) } } } + } } diff --git a/app/src/main/kotlin/com/pitchedapps/frost/injectors/JsInjector.kt b/app/src/main/kotlin/com/pitchedapps/frost/injectors/JsInjector.kt index 58ebb1719..d08211e95 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/injectors/JsInjector.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/injectors/JsInjector.kt @@ -21,125 +21,115 @@ import androidx.annotation.VisibleForTesting import com.pitchedapps.frost.prefs.Prefs import com.pitchedapps.frost.utils.L import com.pitchedapps.frost.web.FrostWebViewClient -import org.apache.commons.text.StringEscapeUtils import kotlin.random.Random +import org.apache.commons.text.StringEscapeUtils class JsBuilder { - private val css = StringBuilder() - private val js = StringBuilder() + private val css = StringBuilder() + private val js = StringBuilder() - private var tag: String? = null + private var tag: String? = null - fun css(css: String): JsBuilder { - this.css.append(StringEscapeUtils.escapeEcmaScript(css)) - return this - } + fun css(css: String): JsBuilder { + this.css.append(StringEscapeUtils.escapeEcmaScript(css)) + return this + } - fun js(content: String): JsBuilder { - this.js.append(content) - return this - } + fun js(content: String): JsBuilder { + this.js.append(content) + return this + } - fun single(tag: String): JsBuilder { - this.tag = TagObfuscator.obfuscateTag(tag) - return this - } + fun single(tag: String): JsBuilder { + this.tag = TagObfuscator.obfuscateTag(tag) + return this + } - fun build() = JsInjector(toString()) + fun build() = JsInjector(toString()) - override fun toString(): String { - val tag = this.tag - val builder = StringBuilder().apply { - append("!function(){") - if (css.isNotBlank()) { - val cssMin = css.replace(Regex("\\s*\n\\s*"), "") - append("var a=document.createElement('style');") - append("a.innerHTML='$cssMin';") - if (tag != null) { - append("a.id='$tag';") - } - append("document.head.appendChild(a);") - } - if (js.isNotBlank()) { - append(js) - } + override fun toString(): String { + val tag = this.tag + val builder = + StringBuilder().apply { + append("!function(){") + if (css.isNotBlank()) { + val cssMin = css.replace(Regex("\\s*\n\\s*"), "") + append("var a=document.createElement('style');") + append("a.innerHTML='$cssMin';") + if (tag != null) { + append("a.id='$tag';") + } + append("document.head.appendChild(a);") } - var content = builder.append("}()").toString() - if (tag != null) { - content = singleInjector(tag, content) + if (js.isNotBlank()) { + append(js) } - return content + } + var content = builder.append("}()").toString() + if (tag != null) { + content = singleInjector(tag, content) } + return content + } - private fun singleInjector(tag: String, content: String) = StringBuilder().apply { + private fun singleInjector(tag: String, content: String) = + StringBuilder() + .apply { append("if (!window.hasOwnProperty(\"$tag\")) {") append("console.log(\"Registering $tag\");") append("window.$tag = true;") append(content) append("}") - }.toString() + } + .toString() } -/** - * Contract for all injectors to allow it to interact properly with a webview - */ +/** Contract for all injectors to allow it to interact properly with a webview */ interface InjectorContract { - fun inject(webView: WebView, prefs: Prefs) + fun inject(webView: WebView, prefs: Prefs) - /** - * Toggle the injector (usually through Prefs - * If false, will fallback to an empty action - */ - fun maybe(enable: Boolean): InjectorContract = if (enable) this else JsActions.EMPTY + /** Toggle the injector (usually through Prefs If false, will fallback to an empty action */ + fun maybe(enable: Boolean): InjectorContract = if (enable) this else JsActions.EMPTY } -/** - * Helper method to inject multiple functions simultaneously with a single callback - */ +/** Helper method to inject multiple functions simultaneously with a single callback */ fun WebView.jsInject(vararg injectors: InjectorContract, prefs: Prefs) { - injectors.asSequence().filter { it != JsActions.EMPTY }.forEach { - it.inject(this, prefs) - } + injectors.asSequence().filter { it != JsActions.EMPTY }.forEach { it.inject(this, prefs) } } fun FrostWebViewClient.jsInject(vararg injectors: InjectorContract, prefs: Prefs) = - web.jsInject(*injectors, prefs = prefs) + web.jsInject(*injectors, prefs = prefs) -/** - * Wrapper class to convert a function into an injector - */ +/** Wrapper class to convert a function into an injector */ class JsInjector(val function: String) : InjectorContract { - override fun inject(webView: WebView, prefs: Prefs) = - webView.evaluateJavascript(function, null) + override fun inject(webView: WebView, prefs: Prefs) = webView.evaluateJavascript(function, null) } -/** - * Helper object to obfuscate window tags for JS injection. - */ +/** Helper object to obfuscate window tags for JS injection. */ @VisibleForTesting internal object TagObfuscator { - fun obfuscateTag(tag: String): String { - val rnd = Random(tag.hashCode() + salt) - val obfuscated = buildString { - append(prefix) - append('_') - appendRandomChars(rnd, 16) - } - L.v { "TagObfuscator: Obfuscating tag '$tag' to '$obfuscated'" } - return obfuscated + fun obfuscateTag(tag: String): String { + val rnd = Random(tag.hashCode() + salt) + val obfuscated = buildString { + append(prefix) + append('_') + appendRandomChars(rnd, 16) } + L.v { "TagObfuscator: Obfuscating tag '$tag' to '$obfuscated'" } + return obfuscated + } - private val salt: Long = System.currentTimeMillis() + private val salt: Long = System.currentTimeMillis() - private val prefix: String by lazy { - val rnd = Random(System.currentTimeMillis()) - buildString { appendRandomChars(rnd, 8) } - } - - private fun Appendable.appendRandomChars(random: Random, count: Int) { - for (i in 1..count) { - append('a' + random.nextInt(26)) - } + private val prefix: String by lazy { + val rnd = Random(System.currentTimeMillis()) + buildString { appendRandomChars(rnd, 8) } + } + + private fun Appendable.appendRandomChars(random: Random, count: Int) { + for (i in 1..count) { + append('a' + random.nextInt(26)) } + } } diff --git a/app/src/main/kotlin/com/pitchedapps/frost/injectors/ThemeProvider.kt b/app/src/main/kotlin/com/pitchedapps/frost/injectors/ThemeProvider.kt index 069c5d904..cefe688d1 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/injectors/ThemeProvider.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/injectors/ThemeProvider.kt @@ -35,157 +35,151 @@ import dagger.Module import dagger.hilt.InstallIn import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext import java.io.BufferedReader import java.io.FileNotFoundException import javax.inject.Inject import javax.inject.Singleton +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext interface ThemeProvider { - val textColor: Int + val textColor: Int - val accentColor: Int + val accentColor: Int - val accentColorForWhite: Int + val accentColorForWhite: Int - val nativeBgColor: Int + val nativeBgColor: Int - fun nativeBgColor(unread: Boolean): Int + fun nativeBgColor(unread: Boolean): Int - val bgColor: Int + val bgColor: Int - val headerColor: Int + val headerColor: Int - val iconColor: Int + val iconColor: Int - val isCustomTheme: Boolean + val isCustomTheme: Boolean - /** - * Note that while this can be loaded from any thread, it is typically done through [preload]] - */ - fun injector(category: ThemeCategory): InjectorContract + /** Note that while this can be loaded from any thread, it is typically done through [preload]] */ + fun injector(category: ThemeCategory): InjectorContract - fun setTheme(id: Int) + fun setTheme(id: Int) - fun reset() + fun reset() - suspend fun preload() + suspend fun preload() } /** - * Provides [InjectorContract] for each [ThemeCategory]. - * Can be reloaded to take in changes from [Prefs] + * Provides [InjectorContract] for each [ThemeCategory]. Can be reloaded to take in changes from + * [Prefs] */ -class ThemeProviderImpl @Inject internal constructor( - @ApplicationContext private val context: Context, - private val prefs: Prefs -) : ThemeProvider { +class ThemeProviderImpl +@Inject +internal constructor(@ApplicationContext private val context: Context, private val prefs: Prefs) : + ThemeProvider { - private var theme: Theme = Theme.values[prefs.theme] - set(value) { - field = value - prefs.theme = value.ordinal - } - - private val injectors: MutableMap = mutableMapOf() - - override val textColor: Int - get() = theme.textColorGetter(prefs) - - override val accentColor: Int - get() = theme.accentColorGetter(prefs) - - override val accentColorForWhite: Int - get() = when { - accentColor.isColorVisibleOn(Color.WHITE) -> accentColor - textColor.isColorVisibleOn(Color.WHITE) -> textColor - else -> FACEBOOK_BLUE - } - - override val nativeBgColor: Int - get() = bgColor.withAlpha(30) - - override fun nativeBgColor(unread: Boolean) = bgColor - .colorToForeground(if (unread) 0.7f else 0.0f) - .withAlpha(30) - - override val bgColor: Int - get() = theme.backgroundColorGetter(prefs) - - override val headerColor: Int - get() = theme.headerColorGetter(prefs) - - override val iconColor: Int - get() = theme.iconColorGetter(prefs) - - override val isCustomTheme: Boolean - get() = theme == Theme.CUSTOM - - override fun injector(category: ThemeCategory): InjectorContract = - injectors.getOrPut(category) { createInjector(category) } - - /** - * Note that while this can be loaded from any thread, it is typically done through [preload] - */ - private fun createInjector(category: ThemeCategory): InjectorContract { - val file = theme.file ?: return JsActions.EMPTY - try { - var content = - context.assets.open("css/${category.folder}/themes/$file").bufferedReader() - .use(BufferedReader::readText) - if (theme == Theme.CUSTOM) { - val bt = if (Color.alpha(bgColor) == 255) - bgColor.toRgbaString() - else - "transparent" - - val bb = bgColor.colorToForeground(0.35f) - - content = content - .replace("\$T\$", textColor.toRgbaString()) - .replace("\$TT\$", textColor.colorToBackground(0.05f).toRgbaString()) - .replace("\$TD\$", textColor.adjustAlpha(0.6f).toRgbaString()) - .replace("\$A\$", accentColor.toRgbaString()) - .replace("\$AT\$", iconColor.toRgbaString()) - .replace("\$B\$", bgColor.toRgbaString()) - .replace("\$BT\$", bt) - .replace("\$BBT\$", bb.withAlpha(51).toRgbaString()) - .replace("\$O\$", bgColor.withAlpha(255).toRgbaString()) - .replace("\$OO\$", bb.withAlpha(255).toRgbaString()) - .replace("\$D\$", textColor.adjustAlpha(0.3f).toRgbaString()) - .replace("\$TI\$", bb.withAlpha(60).toRgbaString()) - .replace("\$C\$", bt) - } - return JsBuilder().css(content).build() - } catch (e: FileNotFoundException) { - L.e(e) { "CssAssets file not found" } - return JsActions.EMPTY - } + private var theme: Theme = Theme.values[prefs.theme] + set(value) { + field = value + prefs.theme = value.ordinal } - override fun setTheme(id: Int) { - if (theme.ordinal == id) return - theme = Theme.values[id] - reset() - } + private val injectors: MutableMap = mutableMapOf() - override fun reset() { - injectors.clear() - } + override val textColor: Int + get() = theme.textColorGetter(prefs) - override suspend fun preload() { - withContext(Dispatchers.IO) { - reset() - ThemeCategory.values().forEach { injector(it) } - } + override val accentColor: Int + get() = theme.accentColorGetter(prefs) + + override val accentColorForWhite: Int + get() = + when { + accentColor.isColorVisibleOn(Color.WHITE) -> accentColor + textColor.isColorVisibleOn(Color.WHITE) -> textColor + else -> FACEBOOK_BLUE + } + + override val nativeBgColor: Int + get() = bgColor.withAlpha(30) + + override fun nativeBgColor(unread: Boolean) = + bgColor.colorToForeground(if (unread) 0.7f else 0.0f).withAlpha(30) + + override val bgColor: Int + get() = theme.backgroundColorGetter(prefs) + + override val headerColor: Int + get() = theme.headerColorGetter(prefs) + + override val iconColor: Int + get() = theme.iconColorGetter(prefs) + + override val isCustomTheme: Boolean + get() = theme == Theme.CUSTOM + + override fun injector(category: ThemeCategory): InjectorContract = + injectors.getOrPut(category) { createInjector(category) } + + /** Note that while this can be loaded from any thread, it is typically done through [preload] */ + private fun createInjector(category: ThemeCategory): InjectorContract { + val file = theme.file ?: return JsActions.EMPTY + try { + var content = + context.assets + .open("css/${category.folder}/themes/$file") + .bufferedReader() + .use(BufferedReader::readText) + if (theme == Theme.CUSTOM) { + val bt = if (Color.alpha(bgColor) == 255) bgColor.toRgbaString() else "transparent" + + val bb = bgColor.colorToForeground(0.35f) + + content = + content + .replace("\$T\$", textColor.toRgbaString()) + .replace("\$TT\$", textColor.colorToBackground(0.05f).toRgbaString()) + .replace("\$TD\$", textColor.adjustAlpha(0.6f).toRgbaString()) + .replace("\$A\$", accentColor.toRgbaString()) + .replace("\$AT\$", iconColor.toRgbaString()) + .replace("\$B\$", bgColor.toRgbaString()) + .replace("\$BT\$", bt) + .replace("\$BBT\$", bb.withAlpha(51).toRgbaString()) + .replace("\$O\$", bgColor.withAlpha(255).toRgbaString()) + .replace("\$OO\$", bb.withAlpha(255).toRgbaString()) + .replace("\$D\$", textColor.adjustAlpha(0.3f).toRgbaString()) + .replace("\$TI\$", bb.withAlpha(60).toRgbaString()) + .replace("\$C\$", bt) + } + return JsBuilder().css(content).build() + } catch (e: FileNotFoundException) { + L.e(e) { "CssAssets file not found" } + return JsActions.EMPTY } + } + + override fun setTheme(id: Int) { + if (theme.ordinal == id) return + theme = Theme.values[id] + reset() + } + + override fun reset() { + injectors.clear() + } + + override suspend fun preload() { + withContext(Dispatchers.IO) { + reset() + ThemeCategory.values().forEach { injector(it) } + } + } } @Module @InstallIn(SingletonComponent::class) interface ThemeProviderModule { - @Binds - @Singleton - fun themeProvider(to: ThemeProviderImpl): ThemeProvider + @Binds @Singleton fun themeProvider(to: ThemeProviderImpl): ThemeProvider } diff --git a/app/src/main/kotlin/com/pitchedapps/frost/intro/IntroFragmentTheme.kt b/app/src/main/kotlin/com/pitchedapps/frost/intro/IntroFragmentTheme.kt index 662c44e58..1f8f89365 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/intro/IntroFragmentTheme.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/intro/IntroFragmentTheme.kt @@ -24,52 +24,48 @@ import com.pitchedapps.frost.activities.IntroActivity import com.pitchedapps.frost.databinding.IntroThemeBinding import com.pitchedapps.frost.enums.Theme -/** - * Created by Allan Wang on 2017-07-28. - */ +/** Created by Allan Wang on 2017-07-28. */ class IntroFragmentTheme : BaseIntroFragment(R.layout.intro_theme) { - private lateinit var binding: IntroThemeBinding + private lateinit var binding: IntroThemeBinding - val themeList - get() = with(binding) { - listOf(introThemeLight, introThemeDark, introThemeAmoled, introThemeGlass) - } + val themeList + get() = + with(binding) { listOf(introThemeLight, introThemeDark, introThemeAmoled, introThemeGlass) } - override fun viewArray(): Array> = with(binding) { - arrayOf( - arrayOf(title), - arrayOf(introThemeLight, introThemeDark), - arrayOf(introThemeAmoled, introThemeGlass) - ) + override fun viewArray(): Array> = + with(binding) { + arrayOf( + arrayOf(title), + arrayOf(introThemeLight, introThemeDark), + arrayOf(introThemeAmoled, introThemeGlass) + ) } - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - binding = IntroThemeBinding.bind(view) - binding.init() - } + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + binding = IntroThemeBinding.bind(view) + binding.init() + } - private fun IntroThemeBinding.init() { - introThemeLight.setThemeClick(Theme.LIGHT) - introThemeDark.setThemeClick(Theme.DARK) - introThemeAmoled.setThemeClick(Theme.AMOLED) - introThemeGlass.setThemeClick(Theme.GLASS) - val currentTheme = prefs.theme - 1 - if (currentTheme in 0..3) - themeList.forEachIndexed { index, v -> - v.scaleXY = if (index == currentTheme) 1.6f else 0.8f - } - } + private fun IntroThemeBinding.init() { + introThemeLight.setThemeClick(Theme.LIGHT) + introThemeDark.setThemeClick(Theme.DARK) + introThemeAmoled.setThemeClick(Theme.AMOLED) + introThemeGlass.setThemeClick(Theme.GLASS) + val currentTheme = prefs.theme - 1 + if (currentTheme in 0..3) + themeList.forEachIndexed { index, v -> v.scaleXY = if (index == currentTheme) 1.6f else 0.8f } + } - private fun View.setThemeClick(theme: Theme) { - setOnClickListener { v -> - themeProvider.setTheme(theme.ordinal) - (activity as IntroActivity).apply { - binding.ripple.ripple(themeProvider.bgColor, v.x + v.pivotX, v.y + v.pivotY) - theme() - } - themeList.forEach { it.animate().scaleXY(if (it == this) 1.6f else 0.8f).start() } - } + private fun View.setThemeClick(theme: Theme) { + setOnClickListener { v -> + themeProvider.setTheme(theme.ordinal) + (activity as IntroActivity).apply { + binding.ripple.ripple(themeProvider.bgColor, v.x + v.pivotX, v.y + v.pivotY) + theme() + } + themeList.forEach { it.animate().scaleXY(if (it == this) 1.6f else 0.8f).start() } } + } } diff --git a/app/src/main/kotlin/com/pitchedapps/frost/intro/IntroImageFragments.kt b/app/src/main/kotlin/com/pitchedapps/frost/intro/IntroImageFragments.kt index e27d4fee3..5f70f3d0a 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/intro/IntroImageFragments.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/intro/IntroImageFragments.kt @@ -32,138 +32,142 @@ import com.pitchedapps.frost.R import com.pitchedapps.frost.utils.launchTabCustomizerActivity import kotlin.math.abs -/** - * Created by Allan Wang on 2017-07-28. - */ -abstract class BaseImageIntroFragment( - val titleRes: Int, - val imageRes: Int, - val descRes: Int -) : BaseIntroFragment(R.layout.intro_image) { +/** Created by Allan Wang on 2017-07-28. */ +abstract class BaseImageIntroFragment(val titleRes: Int, val imageRes: Int, val descRes: Int) : + BaseIntroFragment(R.layout.intro_image) { - val imageDrawable: LayerDrawable by lazyResettableRegistered { image.drawable as LayerDrawable } - val phone: Drawable by lazyResettableRegistered { imageDrawable.findDrawableByLayerId(R.id.intro_phone) } - val screen: Drawable by lazyResettableRegistered { imageDrawable.findDrawableByLayerId(R.id.intro_phone_screen) } - val icon: ImageView by bindViewResettable(R.id.intro_button) + val imageDrawable: LayerDrawable by lazyResettableRegistered { image.drawable as LayerDrawable } + val phone: Drawable by lazyResettableRegistered { + imageDrawable.findDrawableByLayerId(R.id.intro_phone) + } + val screen: Drawable by lazyResettableRegistered { + imageDrawable.findDrawableByLayerId(R.id.intro_phone_screen) + } + val icon: ImageView by bindViewResettable(R.id.intro_button) - override fun viewArray(): Array> = arrayOf(arrayOf(title), arrayOf(desc)) + override fun viewArray(): Array> = arrayOf(arrayOf(title), arrayOf(desc)) - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - title.setText(titleRes) - image.setImageResource(imageRes) - desc.setText(descRes) - super.onViewCreated(view, savedInstanceState) + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + title.setText(titleRes) + image.setImageResource(imageRes) + desc.setText(descRes) + super.onViewCreated(view, savedInstanceState) + } + + override fun themeFragmentImpl() { + super.themeFragmentImpl() + title.setTextColor(themeProvider.textColor) + desc.setTextColor(themeProvider.textColor) + phone.tint(themeProvider.textColor) + screen.tint(themeProvider.bgColor) + } + + fun themeImageComponent(color: Int, vararg id: Int) { + id.forEach { imageDrawable.findDrawableByLayerId(it).tint(color) } + } + + override fun onPageScrolledImpl(positionOffset: Float) { + super.onPageScrolledImpl(positionOffset) + val alpha = ((1 - abs(positionOffset)) * 255).toInt() + // apply alpha to all layers except the phone base + (0 until imageDrawable.numberOfLayers).forEach { + val d = imageDrawable.getDrawable(it) + if (d != phone) d.alpha = alpha } + } - override fun themeFragmentImpl() { - super.themeFragmentImpl() - title.setTextColor(themeProvider.textColor) - desc.setTextColor(themeProvider.textColor) - phone.tint(themeProvider.textColor) - screen.tint(themeProvider.bgColor) - } + fun firstImageFragmentTransition(offset: Float) { + if (offset < 0) image.alpha = 1 + offset + } - fun themeImageComponent(color: Int, vararg id: Int) { - id.forEach { imageDrawable.findDrawableByLayerId(it).tint(color) } - } - - override fun onPageScrolledImpl(positionOffset: Float) { - super.onPageScrolledImpl(positionOffset) - val alpha = ((1 - abs(positionOffset)) * 255).toInt() - // apply alpha to all layers except the phone base - (0 until imageDrawable.numberOfLayers).forEach { - val d = imageDrawable.getDrawable(it) - if (d != phone) d.alpha = alpha - } - } - - fun firstImageFragmentTransition(offset: Float) { - if (offset < 0) - image.alpha = 1 + offset - } - - fun lastImageFragmentTransition(offset: Float) { - if (offset > 0) - image.alpha = 1 - offset - } + fun lastImageFragmentTransition(offset: Float) { + if (offset > 0) image.alpha = 1 - offset + } } -class IntroAccountFragment : BaseImageIntroFragment( +class IntroAccountFragment : + BaseImageIntroFragment( R.string.intro_multiple_accounts, R.drawable.intro_phone_nav, R.string.intro_multiple_accounts_desc -) { + ) { - override fun themeFragmentImpl() { - super.themeFragmentImpl() - themeImageComponent(themeProvider.iconColor, R.id.intro_phone_avatar_1, R.id.intro_phone_avatar_2) - themeImageComponent(themeProvider.bgColor.colorToForeground(), R.id.intro_phone_nav) - themeImageComponent(themeProvider.headerColor, R.id.intro_phone_header) - } + override fun themeFragmentImpl() { + super.themeFragmentImpl() + themeImageComponent( + themeProvider.iconColor, + R.id.intro_phone_avatar_1, + R.id.intro_phone_avatar_2 + ) + themeImageComponent(themeProvider.bgColor.colorToForeground(), R.id.intro_phone_nav) + themeImageComponent(themeProvider.headerColor, R.id.intro_phone_header) + } - override fun onPageScrolledImpl(positionOffset: Float) { - super.onPageScrolledImpl(positionOffset) - firstImageFragmentTransition(positionOffset) - } + override fun onPageScrolledImpl(positionOffset: Float) { + super.onPageScrolledImpl(positionOffset) + firstImageFragmentTransition(positionOffset) + } } -class IntroTabTouchFragment : BaseImageIntroFragment( - R.string.intro_easy_navigation, R.drawable.intro_phone_tab, R.string.intro_easy_navigation_desc -) { +class IntroTabTouchFragment : + BaseImageIntroFragment( + R.string.intro_easy_navigation, + R.drawable.intro_phone_tab, + R.string.intro_easy_navigation_desc + ) { - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - icon.visible().setIcon(GoogleMaterial.Icon.gmd_edit, 24) - icon.setOnClickListener { - activity?.launchTabCustomizerActivity() - } - } + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + icon.visible().setIcon(GoogleMaterial.Icon.gmd_edit, 24) + icon.setOnClickListener { activity?.launchTabCustomizerActivity() } + } - override fun themeFragmentImpl() { - super.themeFragmentImpl() - themeImageComponent( - themeProvider.iconColor, - R.id.intro_phone_icon_1, - R.id.intro_phone_icon_2, - R.id.intro_phone_icon_3, - R.id.intro_phone_icon_4 - ) - themeImageComponent(themeProvider.headerColor, R.id.intro_phone_tab) - themeImageComponent(themeProvider.textColor.withAlpha(80), R.id.intro_phone_icon_ripple) - } + override fun themeFragmentImpl() { + super.themeFragmentImpl() + themeImageComponent( + themeProvider.iconColor, + R.id.intro_phone_icon_1, + R.id.intro_phone_icon_2, + R.id.intro_phone_icon_3, + R.id.intro_phone_icon_4 + ) + themeImageComponent(themeProvider.headerColor, R.id.intro_phone_tab) + themeImageComponent(themeProvider.textColor.withAlpha(80), R.id.intro_phone_icon_ripple) + } } -class IntroTabContextFragment : BaseImageIntroFragment( +class IntroTabContextFragment : + BaseImageIntroFragment( R.string.intro_context_aware, R.drawable.intro_phone_long_press, R.string.intro_context_aware_desc -) { + ) { - override fun themeFragmentImpl() { - super.themeFragmentImpl() - themeImageComponent(themeProvider.headerColor, R.id.intro_phone_toolbar) - themeImageComponent(themeProvider.bgColor.colorToForeground(0.1f), R.id.intro_phone_image) - themeImageComponent( - themeProvider.bgColor.colorToForeground(0.2f), - R.id.intro_phone_like, - R.id.intro_phone_share - ) - themeImageComponent(themeProvider.bgColor.colorToForeground(0.3f), R.id.intro_phone_comment) - themeImageComponent( - themeProvider.bgColor.colorToForeground(0.1f), - R.id.intro_phone_card_1, - R.id.intro_phone_card_2 - ) - themeImageComponent( - themeProvider.textColor, - R.id.intro_phone_image_indicator, - R.id.intro_phone_comment_indicator, - R.id.intro_phone_card_indicator - ) - } + override fun themeFragmentImpl() { + super.themeFragmentImpl() + themeImageComponent(themeProvider.headerColor, R.id.intro_phone_toolbar) + themeImageComponent(themeProvider.bgColor.colorToForeground(0.1f), R.id.intro_phone_image) + themeImageComponent( + themeProvider.bgColor.colorToForeground(0.2f), + R.id.intro_phone_like, + R.id.intro_phone_share + ) + themeImageComponent(themeProvider.bgColor.colorToForeground(0.3f), R.id.intro_phone_comment) + themeImageComponent( + themeProvider.bgColor.colorToForeground(0.1f), + R.id.intro_phone_card_1, + R.id.intro_phone_card_2 + ) + themeImageComponent( + themeProvider.textColor, + R.id.intro_phone_image_indicator, + R.id.intro_phone_comment_indicator, + R.id.intro_phone_card_indicator + ) + } - override fun onPageScrolledImpl(positionOffset: Float) { - super.onPageScrolledImpl(positionOffset) - lastImageFragmentTransition(positionOffset) - } + override fun onPageScrolledImpl(positionOffset: Float) { + super.onPageScrolledImpl(positionOffset) + lastImageFragmentTransition(positionOffset) + } } diff --git a/app/src/main/kotlin/com/pitchedapps/frost/intro/IntroMainFragments.kt b/app/src/main/kotlin/com/pitchedapps/frost/intro/IntroMainFragments.kt index 040fa96f1..2a27380b9 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/intro/IntroMainFragments.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/intro/IntroMainFragments.kt @@ -45,122 +45,118 @@ import kotlin.math.abs * Contains the base, start, and end fragments */ -/** - * The core intro fragment for all other fragments - */ +/** The core intro fragment for all other fragments */ @AndroidEntryPoint abstract class BaseIntroFragment(val layoutRes: Int) : Fragment() { - @Inject - protected lateinit var prefs: Prefs + @Inject protected lateinit var prefs: Prefs - @Inject - protected lateinit var themeProvider: ThemeProvider + @Inject protected lateinit var themeProvider: ThemeProvider - val screenWidth - get() = resources.displayMetrics.widthPixels + val screenWidth + get() = resources.displayMetrics.widthPixels - val lazyRegistry = LazyResettableRegistry() + val lazyRegistry = LazyResettableRegistry() - protected fun translate(offset: Float, views: Array>) { - val maxTranslation = offset * screenWidth - val increment = maxTranslation / views.size - views.forEachIndexed { i, group -> - group.forEach { - it.translationX = - if (offset > 0) -maxTranslation + i * increment else -(i + 1) * increment - it.alpha = 1 - abs(offset) - } - } + protected fun translate(offset: Float, views: Array>) { + val maxTranslation = offset * screenWidth + val increment = maxTranslation / views.size + views.forEachIndexed { i, group -> + group.forEach { + it.translationX = if (offset > 0) -maxTranslation + i * increment else -(i + 1) * increment + it.alpha = 1 - abs(offset) + } } + } - fun lazyResettableRegistered(initializer: () -> T) = lazyRegistry.lazy(initializer) + fun lazyResettableRegistered(initializer: () -> T) = lazyRegistry.lazy(initializer) - /* - * Note that these ids aren't actually inside all layouts - * However, they are in most of them, so they are added here - * for convenience - */ - protected val title: TextView by bindViewResettable(R.id.intro_title) - protected val image: ImageView by bindViewResettable(R.id.intro_image) - protected val desc: TextView by bindViewResettable(R.id.intro_desc) + /* + * Note that these ids aren't actually inside all layouts + * However, they are in most of them, so they are added here + * for convenience + */ + protected val title: TextView by bindViewResettable(R.id.intro_title) + protected val image: ImageView by bindViewResettable(R.id.intro_image) + protected val desc: TextView by bindViewResettable(R.id.intro_desc) - protected fun defaultViewArray(): Array> = - arrayOf(arrayOf(title), arrayOf(image), arrayOf(desc)) + protected fun defaultViewArray(): Array> = + arrayOf(arrayOf(title), arrayOf(image), arrayOf(desc)) - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View? { - return inflater.inflate(layoutRes, container, false) + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View? { + return inflater.inflate(layoutRes, container, false) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + themeFragment() + } + + override fun onDestroyView() { + Kotterknife.reset(this) + lazyRegistry.invalidateAll() + super.onDestroyView() + } + + fun themeFragment() { + if (view != null) themeFragmentImpl() + } + + protected open fun themeFragmentImpl() { + (view as? ViewGroup)?.children?.forEach { + (it as? TextView)?.setTextColor(themeProvider.textColor) } + } - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - themeFragment() - } + protected val viewArray: Array> by lazyResettableRegistered { viewArray() } - override fun onDestroyView() { - Kotterknife.reset(this) - lazyRegistry.invalidateAll() - super.onDestroyView() - } + protected abstract fun viewArray(): Array> - fun themeFragment() { - if (view != null) themeFragmentImpl() - } + fun onPageScrolled(positionOffset: Float) { + if (view != null) onPageScrolledImpl(positionOffset) + } - protected open fun themeFragmentImpl() { - (view as? ViewGroup)?.children?.forEach { (it as? TextView)?.setTextColor(themeProvider.textColor) } - } + protected open fun onPageScrolledImpl(positionOffset: Float) { + translate(positionOffset, viewArray) + } - protected val viewArray: Array> by lazyResettableRegistered { viewArray() } + fun onPageSelected() { + if (view != null) onPageSelectedImpl() + } - protected abstract fun viewArray(): Array> - - fun onPageScrolled(positionOffset: Float) { - if (view != null) onPageScrolledImpl(positionOffset) - } - - protected open fun onPageScrolledImpl(positionOffset: Float) { - translate(positionOffset, viewArray) - } - - fun onPageSelected() { - if (view != null) onPageSelectedImpl() - } - - protected open fun onPageSelectedImpl() { - } + protected open fun onPageSelectedImpl() {} } class IntroFragmentWelcome : BaseIntroFragment(R.layout.intro_welcome) { - override fun viewArray(): Array> = defaultViewArray() + override fun viewArray(): Array> = defaultViewArray() - override fun themeFragmentImpl() { - super.themeFragmentImpl() - image.imageTintList = ColorStateList.valueOf(themeProvider.textColor) - } + override fun themeFragmentImpl() { + super.themeFragmentImpl() + image.imageTintList = ColorStateList.valueOf(themeProvider.textColor) + } } class IntroFragmentEnd : BaseIntroFragment(R.layout.intro_end) { - val container: ConstraintLayout by bindViewResettable(R.id.intro_end_container) + val container: ConstraintLayout by bindViewResettable(R.id.intro_end_container) - override fun viewArray(): Array> = defaultViewArray() + override fun viewArray(): Array> = defaultViewArray() - override fun themeFragmentImpl() { - super.themeFragmentImpl() - image.imageTintList = ColorStateList.valueOf(themeProvider.textColor) - } - - @SuppressLint("ClickableViewAccessibility") - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - container.setOnSingleTapListener { _, event -> - (activity as IntroActivity).finish(event.x, event.y) - } + override fun themeFragmentImpl() { + super.themeFragmentImpl() + image.imageTintList = ColorStateList.valueOf(themeProvider.textColor) + } + + @SuppressLint("ClickableViewAccessibility") + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + container.setOnSingleTapListener { _, event -> + (activity as IntroActivity).finish(event.x, event.y) } + } } diff --git a/app/src/main/kotlin/com/pitchedapps/frost/prefs/OldPrefs.kt b/app/src/main/kotlin/com/pitchedapps/frost/prefs/OldPrefs.kt index cfd8edbd6..792b79872 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/prefs/OldPrefs.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/prefs/OldPrefs.kt @@ -31,109 +31,109 @@ import javax.inject.Inject */ @Deprecated(level = DeprecationLevel.WARNING, message = "Use pref segments") class OldPrefs @Inject internal constructor(factory: KPrefFactory) : - KPref("${BuildConfig.APPLICATION_ID}.prefs", factory) { + KPref("${BuildConfig.APPLICATION_ID}.prefs", factory) { - var lastLaunch: Long by kpref("last_launch", -1L) + var lastLaunch: Long by kpref("last_launch", -1L) - var userId: Long by kpref("user_id", -1L) + var userId: Long by kpref("user_id", -1L) - var prevId: Long by kpref("prev_id", -1L) + var prevId: Long by kpref("prev_id", -1L) - var theme: Int by kpref("theme", 0) + var theme: Int by kpref("theme", 0) - var customTextColor: Int by kpref("color_text", 0xffeceff1.toInt()) + var customTextColor: Int by kpref("color_text", 0xffeceff1.toInt()) - var customAccentColor: Int by kpref("color_accent", 0xff0288d1.toInt()) + var customAccentColor: Int by kpref("color_accent", 0xff0288d1.toInt()) - var customBackgroundColor: Int by kpref("color_bg", 0xff212121.toInt()) + var customBackgroundColor: Int by kpref("color_bg", 0xff212121.toInt()) - var customHeaderColor: Int by kpref("color_header", 0xff01579b.toInt()) + var customHeaderColor: Int by kpref("color_header", 0xff01579b.toInt()) - var customIconColor: Int by kpref("color_icons", 0xffeceff1.toInt()) + var customIconColor: Int by kpref("color_icons", 0xffeceff1.toInt()) - var exitConfirmation: Boolean by kpref("exit_confirmation", true) + var exitConfirmation: Boolean by kpref("exit_confirmation", true) - var notificationFreq: Long by kpref("notification_freq", 15L) + var notificationFreq: Long by kpref("notification_freq", 15L) - var versionCode: Int by kpref("version_code", -1) + var versionCode: Int by kpref("version_code", -1) - var prevVersionCode: Int by kpref("prev_version_code", -1) + var prevVersionCode: Int by kpref("prev_version_code", -1) - var installDate: Long by kpref("install_date", -1L) + var installDate: Long by kpref("install_date", -1L) - var identifier: Int by kpref("identifier", -1) + var identifier: Int by kpref("identifier", -1) - var tintNavBar: Boolean by kpref("tint_nav_bar", true) + var tintNavBar: Boolean by kpref("tint_nav_bar", true) - var webTextScaling: Int by kpref("web_text_scaling", 100) + var webTextScaling: Int by kpref("web_text_scaling", 100) - var feedSort: Int by kpref("feed_sort", FeedSort.DEFAULT.ordinal) + var feedSort: Int by kpref("feed_sort", FeedSort.DEFAULT.ordinal) - var aggressiveRecents: Boolean by kpref("aggressive_recents", false) + var aggressiveRecents: Boolean by kpref("aggressive_recents", false) - var showComposer: Boolean by kpref("status_composer_feed", true) + var showComposer: Boolean by kpref("status_composer_feed", true) - var showSuggestedFriends: Boolean by kpref("suggested_friends_feed", true) + var showSuggestedFriends: Boolean by kpref("suggested_friends_feed", true) - var showSuggestedGroups: Boolean by kpref("suggested_groups_feed", true) + var showSuggestedGroups: Boolean by kpref("suggested_groups_feed", true) - var showFacebookAds: Boolean by kpref("facebook_ads", false) + var showFacebookAds: Boolean by kpref("facebook_ads", false) - var showStories: Boolean by kpref("show_stories", true) + var showStories: Boolean by kpref("show_stories", true) - var animate: Boolean by kpref("fancy_animations", true) + var animate: Boolean by kpref("fancy_animations", true) - var notificationKeywords: Set by kpref("notification_keywords", mutableSetOf()) + var notificationKeywords: Set by kpref("notification_keywords", mutableSetOf()) - var notificationsGeneral: Boolean by kpref("notification_general", true) + var notificationsGeneral: Boolean by kpref("notification_general", true) - var notificationAllAccounts: Boolean by kpref("notification_all_accounts", true) + var notificationAllAccounts: Boolean by kpref("notification_all_accounts", true) - var notificationsInstantMessages: Boolean by kpref("notification_im", true) + var notificationsInstantMessages: Boolean by kpref("notification_im", true) - var notificationsImAllAccounts: Boolean by kpref("notification_im_all_accounts", false) + var notificationsImAllAccounts: Boolean by kpref("notification_im_all_accounts", false) - var notificationVibrate: Boolean by kpref("notification_vibrate", true) + var notificationVibrate: Boolean by kpref("notification_vibrate", true) - var notificationSound: Boolean by kpref("notification_sound", true) + var notificationSound: Boolean by kpref("notification_sound", true) - var notificationRingtone: String by kpref("notification_ringtone", "") + var notificationRingtone: String by kpref("notification_ringtone", "") - var messageRingtone: String by kpref("message_ringtone", "") + var messageRingtone: String by kpref("message_ringtone", "") - var notificationLights: Boolean by kpref("notification_lights", true) + var notificationLights: Boolean by kpref("notification_lights", true) - var messageScrollToBottom: Boolean by kpref("message_scroll_to_bottom", false) + var messageScrollToBottom: Boolean by kpref("message_scroll_to_bottom", false) - var enablePip: Boolean by kpref("enable_pip", true) + var enablePip: Boolean by kpref("enable_pip", true) - /** - * Despite the naming, this toggle currently only enables debug logging. - * Verbose is never logged in release builds. - */ - var verboseLogging: Boolean by kpref("verbose_logging", false) + /** + * Despite the naming, this toggle currently only enables debug logging. Verbose is never logged + * in release builds. + */ + var verboseLogging: Boolean by kpref("verbose_logging", false) - var biometricsEnabled: Boolean by kpref("biometrics_enabled", false) + var biometricsEnabled: Boolean by kpref("biometrics_enabled", false) - var overlayEnabled: Boolean by kpref("overlay_enabled", true) + var overlayEnabled: Boolean by kpref("overlay_enabled", true) - var overlayFullScreenSwipe: Boolean by kpref("overlay_full_screen_swipe", true) + var overlayFullScreenSwipe: Boolean by kpref("overlay_full_screen_swipe", true) - var viewpagerSwipe: Boolean by kpref("viewpager_swipe", true) + var viewpagerSwipe: Boolean by kpref("viewpager_swipe", true) - var loadMediaOnMeteredNetwork: Boolean by kpref("media_on_metered_network", true) + var loadMediaOnMeteredNetwork: Boolean by kpref("media_on_metered_network", true) - var debugSettings: Boolean by kpref("debug_settings", false) + var debugSettings: Boolean by kpref("debug_settings", false) - var linksInDefaultApp: Boolean by kpref("link_in_default_app", false) + var linksInDefaultApp: Boolean by kpref("link_in_default_app", false) - var mainActivityLayoutType: Int by kpref("main_activity_layout_type", 0) + var mainActivityLayoutType: Int by kpref("main_activity_layout_type", 0) - var blackMediaBg: Boolean by kpref("black_media_bg", false) + var blackMediaBg: Boolean by kpref("black_media_bg", false) - var autoRefreshFeed: Boolean by kpref("auto_refresh_feed", false) + var autoRefreshFeed: Boolean by kpref("auto_refresh_feed", false) - var showCreateFab: Boolean by kpref("show_create_fab", true) + var showCreateFab: Boolean by kpref("show_create_fab", true) - var fullSizeImage: Boolean by kpref("full_size_image", false) + var fullSizeImage: Boolean by kpref("full_size_image", false) } diff --git a/app/src/main/kotlin/com/pitchedapps/frost/prefs/Prefs.kt b/app/src/main/kotlin/com/pitchedapps/frost/prefs/Prefs.kt index 0cf97c56a..238499981 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/prefs/Prefs.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/prefs/Prefs.kt @@ -41,92 +41,76 @@ import javax.inject.Inject import javax.inject.Singleton /** - * [Prefs] is no longer an actual pref, but we will expose the reset function as it is used elsewhere + * [Prefs] is no longer an actual pref, but we will expose the reset function as it is used + * elsewhere */ interface PrefsBase { - fun reset() - fun deleteKeys(vararg keys: String) + fun reset() + fun deleteKeys(vararg keys: String) } interface Prefs : - BehaviourPrefs, - CorePrefs, - FeedPrefs, - NotifPrefs, - ThemePrefs, - ShowcasePrefs, - PrefsBase + BehaviourPrefs, CorePrefs, FeedPrefs, NotifPrefs, ThemePrefs, ShowcasePrefs, PrefsBase -class PrefsImpl @Inject internal constructor( - private val behaviourPrefs: BehaviourPrefs, - private val corePrefs: CorePrefs, - private val feedPrefs: FeedPrefs, - private val notifPrefs: NotifPrefs, - private val themePrefs: ThemePrefs, - private val showcasePrefs: ShowcasePrefs -) : Prefs, - BehaviourPrefs by behaviourPrefs, - CorePrefs by corePrefs, - FeedPrefs by feedPrefs, - NotifPrefs by notifPrefs, - ThemePrefs by themePrefs, - ShowcasePrefs by showcasePrefs { +class PrefsImpl +@Inject +internal constructor( + private val behaviourPrefs: BehaviourPrefs, + private val corePrefs: CorePrefs, + private val feedPrefs: FeedPrefs, + private val notifPrefs: NotifPrefs, + private val themePrefs: ThemePrefs, + private val showcasePrefs: ShowcasePrefs +) : + Prefs, + BehaviourPrefs by behaviourPrefs, + CorePrefs by corePrefs, + FeedPrefs by feedPrefs, + NotifPrefs by notifPrefs, + ThemePrefs by themePrefs, + ShowcasePrefs by showcasePrefs { - override fun reset() { - behaviourPrefs.reset() - corePrefs.reset() - feedPrefs.reset() - notifPrefs.reset() - themePrefs.reset() - showcasePrefs.reset() - } + override fun reset() { + behaviourPrefs.reset() + corePrefs.reset() + feedPrefs.reset() + notifPrefs.reset() + themePrefs.reset() + showcasePrefs.reset() + } - override fun deleteKeys(vararg keys: String) { - behaviourPrefs.deleteKeys() - corePrefs.deleteKeys() - feedPrefs.deleteKeys() - notifPrefs.deleteKeys() - themePrefs.deleteKeys() - showcasePrefs.deleteKeys() - } + override fun deleteKeys(vararg keys: String) { + behaviourPrefs.deleteKeys() + corePrefs.deleteKeys() + feedPrefs.deleteKeys() + notifPrefs.deleteKeys() + themePrefs.deleteKeys() + showcasePrefs.deleteKeys() + } } @Module @InstallIn(SingletonComponent::class) interface PrefModule { - @Binds - @Singleton - fun behaviour(to: BehaviourPrefsImpl): BehaviourPrefs + @Binds @Singleton fun behaviour(to: BehaviourPrefsImpl): BehaviourPrefs - @Binds - @Singleton - fun core(to: CorePrefsImpl): CorePrefs + @Binds @Singleton fun core(to: CorePrefsImpl): CorePrefs - @Binds - @Singleton - fun feed(to: FeedPrefsImpl): FeedPrefs + @Binds @Singleton fun feed(to: FeedPrefsImpl): FeedPrefs - @Binds - @Singleton - fun notif(to: NotifPrefsImpl): NotifPrefs + @Binds @Singleton fun notif(to: NotifPrefsImpl): NotifPrefs - @Binds - @Singleton - fun theme(to: ThemePrefsImpl): ThemePrefs + @Binds @Singleton fun theme(to: ThemePrefsImpl): ThemePrefs - @Binds - @Singleton - fun showcase(to: ShowcasePrefsImpl): ShowcasePrefs + @Binds @Singleton fun showcase(to: ShowcasePrefsImpl): ShowcasePrefs - @Binds - @Singleton - fun prefs(to: PrefsImpl): Prefs + @Binds @Singleton fun prefs(to: PrefsImpl): Prefs } @Module @InstallIn(SingletonComponent::class) object PrefFactoryModule { - @Provides - @Singleton - fun factory(@ApplicationContext context: Context): KPrefFactory = KPrefFactoryAndroid(context) + @Provides + @Singleton + fun factory(@ApplicationContext context: Context): KPrefFactory = KPrefFactoryAndroid(context) } diff --git a/app/src/main/kotlin/com/pitchedapps/frost/prefs/sections/BehaviourPrefs.kt b/app/src/main/kotlin/com/pitchedapps/frost/prefs/sections/BehaviourPrefs.kt index 8842d9889..a4968e5a3 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/prefs/sections/BehaviourPrefs.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/prefs/sections/BehaviourPrefs.kt @@ -24,87 +24,67 @@ import com.pitchedapps.frost.prefs.PrefsBase import javax.inject.Inject interface BehaviourPrefs : PrefsBase { - var biometricsEnabled: Boolean + var biometricsEnabled: Boolean - var overlayEnabled: Boolean + var overlayEnabled: Boolean - var overlayFullScreenSwipe: Boolean + var overlayFullScreenSwipe: Boolean - var viewpagerSwipe: Boolean + var viewpagerSwipe: Boolean - var loadMediaOnMeteredNetwork: Boolean + var loadMediaOnMeteredNetwork: Boolean - var debugSettings: Boolean + var debugSettings: Boolean - var linksInDefaultApp: Boolean + var linksInDefaultApp: Boolean - var blackMediaBg: Boolean + var blackMediaBg: Boolean - var autoRefreshFeed: Boolean + var autoRefreshFeed: Boolean - var showCreateFab: Boolean + var showCreateFab: Boolean - var fullSizeImage: Boolean + var fullSizeImage: Boolean - var autoExpandTextBox: Boolean + var autoExpandTextBox: Boolean } -class BehaviourPrefsImpl @Inject internal constructor( - factory: KPrefFactory, - oldPrefs: OldPrefs, +class BehaviourPrefsImpl +@Inject +internal constructor( + factory: KPrefFactory, + oldPrefs: OldPrefs, ) : KPref("${BuildConfig.APPLICATION_ID}.prefs.behaviour", factory), BehaviourPrefs { - override var biometricsEnabled: Boolean by kpref( - "biometrics_enabled", - oldPrefs.biometricsEnabled /* false */ - ) + override var biometricsEnabled: Boolean by + kpref("biometrics_enabled", oldPrefs.biometricsEnabled /* false */) - override var overlayEnabled: Boolean by kpref( - "overlay_enabled", - oldPrefs.overlayEnabled /* true */ - ) + override var overlayEnabled: Boolean by + kpref("overlay_enabled", oldPrefs.overlayEnabled /* true */) - override var overlayFullScreenSwipe: Boolean by kpref( - "overlay_full_screen_swipe", - oldPrefs.overlayFullScreenSwipe /* true */ - ) + override var overlayFullScreenSwipe: Boolean by + kpref("overlay_full_screen_swipe", oldPrefs.overlayFullScreenSwipe /* true */) - override var viewpagerSwipe: Boolean by kpref( - "viewpager_swipe", - oldPrefs.viewpagerSwipe /* true */ - ) + override var viewpagerSwipe: Boolean by + kpref("viewpager_swipe", oldPrefs.viewpagerSwipe /* true */) - override var loadMediaOnMeteredNetwork: Boolean by kpref( - "media_on_metered_network", - oldPrefs.loadMediaOnMeteredNetwork /* true */ - ) + override var loadMediaOnMeteredNetwork: Boolean by + kpref("media_on_metered_network", oldPrefs.loadMediaOnMeteredNetwork /* true */) - override var debugSettings: Boolean by kpref( - "debug_settings", - oldPrefs.debugSettings /* false */ - ) + override var debugSettings: Boolean by kpref("debug_settings", oldPrefs.debugSettings /* false */) - override var linksInDefaultApp: Boolean by kpref( - "link_in_default_app", - oldPrefs.linksInDefaultApp /* false */ - ) + override var linksInDefaultApp: Boolean by + kpref("link_in_default_app", oldPrefs.linksInDefaultApp /* false */) - override var blackMediaBg: Boolean by kpref("black_media_bg", oldPrefs.blackMediaBg /* false */) + override var blackMediaBg: Boolean by kpref("black_media_bg", oldPrefs.blackMediaBg /* false */) - override var autoRefreshFeed: Boolean by kpref( - "auto_refresh_feed", - oldPrefs.autoRefreshFeed /* false */ - ) + override var autoRefreshFeed: Boolean by + kpref("auto_refresh_feed", oldPrefs.autoRefreshFeed /* false */) - override var showCreateFab: Boolean by kpref( - "show_create_fab", - oldPrefs.showCreateFab /* true */ - ) + override var showCreateFab: Boolean by kpref("show_create_fab", oldPrefs.showCreateFab /* true */) - override var fullSizeImage: Boolean by kpref( - "full_size_image", - oldPrefs.fullSizeImage /* false */ - ) + override var fullSizeImage: Boolean by + kpref("full_size_image", oldPrefs.fullSizeImage /* false */) - override var autoExpandTextBox: Boolean by kpref("auto_expand_text_box", true) + override var autoExpandTextBox: Boolean by kpref("auto_expand_text_box", true) } diff --git a/app/src/main/kotlin/com/pitchedapps/frost/prefs/sections/CorePrefs.kt b/app/src/main/kotlin/com/pitchedapps/frost/prefs/sections/CorePrefs.kt index 880a7225e..5da687ca5 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/prefs/sections/CorePrefs.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/prefs/sections/CorePrefs.kt @@ -24,78 +24,71 @@ import com.pitchedapps.frost.prefs.PrefsBase import javax.inject.Inject interface CorePrefs : PrefsBase { - var lastLaunch: Long + var lastLaunch: Long - var userId: Long + var userId: Long - var prevId: Long + var prevId: Long - val frostId: String + val frostId: String - var versionCode: Int + var versionCode: Int - var prevVersionCode: Int + var prevVersionCode: Int - var installDate: Long + var installDate: Long - var identifier: Int + var identifier: Int - /** - * Despite the naming, this toggle currently only enables debug logging. - * Verbose is never logged in release builds. - */ - var verboseLogging: Boolean + /** + * Despite the naming, this toggle currently only enables debug logging. Verbose is never logged + * in release builds. + */ + var verboseLogging: Boolean - var enablePip: Boolean + var enablePip: Boolean - var exitConfirmation: Boolean + var exitConfirmation: Boolean - var animate: Boolean + var animate: Boolean - var messageScrollToBottom: Boolean + var messageScrollToBottom: Boolean } -class CorePrefsImpl @Inject internal constructor( - factory: KPrefFactory, - oldPrefs: OldPrefs, +class CorePrefsImpl +@Inject +internal constructor( + factory: KPrefFactory, + oldPrefs: OldPrefs, ) : KPref("${BuildConfig.APPLICATION_ID}.prefs.core", factory), CorePrefs { - override var lastLaunch: Long by kpref("last_launch", oldPrefs.lastLaunch /* -1L */) + override var lastLaunch: Long by kpref("last_launch", oldPrefs.lastLaunch /* -1L */) - override var userId: Long by kpref("user_id", oldPrefs.userId /* -1L */) + override var userId: Long by kpref("user_id", oldPrefs.userId /* -1L */) - override var prevId: Long by kpref("prev_id", oldPrefs.prevId /* -1L */) + override var prevId: Long by kpref("prev_id", oldPrefs.prevId /* -1L */) - override val frostId: String - get() = "$installDate-$identifier" + override val frostId: String + get() = "$installDate-$identifier" - override var versionCode: Int by kpref("version_code", oldPrefs.versionCode /* -1 */) + override var versionCode: Int by kpref("version_code", oldPrefs.versionCode /* -1 */) - override var prevVersionCode: Int by kpref( - "prev_version_code", - oldPrefs.prevVersionCode /* -1 */ - ) + override var prevVersionCode: Int by kpref("prev_version_code", oldPrefs.prevVersionCode /* -1 */) - override var installDate: Long by kpref("install_date", oldPrefs.installDate /* -1L */) + override var installDate: Long by kpref("install_date", oldPrefs.installDate /* -1L */) - override var identifier: Int by kpref("identifier", oldPrefs.identifier /* -1 */) + override var identifier: Int by kpref("identifier", oldPrefs.identifier /* -1 */) - override var verboseLogging: Boolean by kpref( - "verbose_logging", - oldPrefs.verboseLogging /* false */ - ) + override var verboseLogging: Boolean by + kpref("verbose_logging", oldPrefs.verboseLogging /* false */) - override var enablePip: Boolean by kpref("enable_pip", oldPrefs.enablePip /* true */) + override var enablePip: Boolean by kpref("enable_pip", oldPrefs.enablePip /* true */) - override var exitConfirmation: Boolean by kpref( - "exit_confirmation", - oldPrefs.exitConfirmation /* true */ - ) + override var exitConfirmation: Boolean by + kpref("exit_confirmation", oldPrefs.exitConfirmation /* true */) - override var animate: Boolean by kpref("fancy_animations", oldPrefs.animate /* true */) + override var animate: Boolean by kpref("fancy_animations", oldPrefs.animate /* true */) - override var messageScrollToBottom: Boolean by kpref( - "message_scroll_to_bottom", - oldPrefs.messageScrollToBottom /* false */ - ) + override var messageScrollToBottom: Boolean by + kpref("message_scroll_to_bottom", oldPrefs.messageScrollToBottom /* false */) } diff --git a/app/src/main/kotlin/com/pitchedapps/frost/prefs/sections/FeedPrefs.kt b/app/src/main/kotlin/com/pitchedapps/frost/prefs/sections/FeedPrefs.kt index 00df97434..5d2c8195a 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/prefs/sections/FeedPrefs.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/prefs/sections/FeedPrefs.kt @@ -25,79 +25,62 @@ import com.pitchedapps.frost.prefs.PrefsBase import javax.inject.Inject interface FeedPrefs : PrefsBase { - var webTextScaling: Int + var webTextScaling: Int - var feedSort: Int + var feedSort: Int - var aggressiveRecents: Boolean + var aggressiveRecents: Boolean - var showComposer: Boolean + var showComposer: Boolean - var showSuggestedFriends: Boolean + var showSuggestedFriends: Boolean - var showSuggestedGroups: Boolean + var showSuggestedGroups: Boolean - var showFacebookAds: Boolean + var showFacebookAds: Boolean - var showStories: Boolean + var showStories: Boolean - var mainActivityLayoutType: Int + var mainActivityLayoutType: Int - val mainActivityLayout: MainActivityLayout + val mainActivityLayout: MainActivityLayout - var showPostActions: Boolean + var showPostActions: Boolean - var showPostReactions: Boolean + var showPostReactions: Boolean } -class FeedPrefsImpl @Inject internal constructor( - factory: KPrefFactory, - oldPrefs: OldPrefs -) : KPref("${BuildConfig.APPLICATION_ID}.prefs.feed", factory), FeedPrefs { +class FeedPrefsImpl @Inject internal constructor(factory: KPrefFactory, oldPrefs: OldPrefs) : + KPref("${BuildConfig.APPLICATION_ID}.prefs.feed", factory), FeedPrefs { - override var webTextScaling: Int by kpref("web_text_scaling", oldPrefs.webTextScaling /* 100 */) + override var webTextScaling: Int by kpref("web_text_scaling", oldPrefs.webTextScaling /* 100 */) - override var feedSort: Int by kpref( - "feed_sort", - oldPrefs.feedSort /* FeedSort.DEFAULT.ordinal */ - ) + override var feedSort: Int by kpref("feed_sort", oldPrefs.feedSort /* FeedSort.DEFAULT.ordinal */) - override var aggressiveRecents: Boolean by kpref( - "aggressive_recents", - oldPrefs.aggressiveRecents /* false */ - ) + override var aggressiveRecents: Boolean by + kpref("aggressive_recents", oldPrefs.aggressiveRecents /* false */) - override var showComposer: Boolean by kpref( - "status_composer_feed", - oldPrefs.showComposer /* true */ - ) + override var showComposer: Boolean by + kpref("status_composer_feed", oldPrefs.showComposer /* true */) - override var showSuggestedFriends: Boolean by kpref( - "suggested_friends_feed", - oldPrefs.showSuggestedFriends /* true */ - ) + override var showSuggestedFriends: Boolean by + kpref("suggested_friends_feed", oldPrefs.showSuggestedFriends /* true */) - override var showSuggestedGroups: Boolean by kpref( - "suggested_groups_feed", - oldPrefs.showSuggestedGroups /* true */ - ) + override var showSuggestedGroups: Boolean by + kpref("suggested_groups_feed", oldPrefs.showSuggestedGroups /* true */) - override var showFacebookAds: Boolean by kpref( - "facebook_ads", - oldPrefs.showFacebookAds /* false */ - ) + override var showFacebookAds: Boolean by + kpref("facebook_ads", oldPrefs.showFacebookAds /* false */) - override var showStories: Boolean by kpref("show_stories", oldPrefs.showStories /* true */) + override var showStories: Boolean by kpref("show_stories", oldPrefs.showStories /* true */) - override var mainActivityLayoutType: Int by kpref( - "main_activity_layout_type", - oldPrefs.mainActivityLayoutType /* 0 */ - ) + override var mainActivityLayoutType: Int by + kpref("main_activity_layout_type", oldPrefs.mainActivityLayoutType /* 0 */) - override val mainActivityLayout: MainActivityLayout - get() = MainActivityLayout(mainActivityLayoutType) + override val mainActivityLayout: MainActivityLayout + get() = MainActivityLayout(mainActivityLayoutType) - override var showPostActions: Boolean by kpref("show_post_actions", true) + override var showPostActions: Boolean by kpref("show_post_actions", true) - override var showPostReactions: Boolean by kpref("show_post_reactions", true) + override var showPostReactions: Boolean by kpref("show_post_reactions", true) } diff --git a/app/src/main/kotlin/com/pitchedapps/frost/prefs/sections/NotifPrefs.kt b/app/src/main/kotlin/com/pitchedapps/frost/prefs/sections/NotifPrefs.kt index 5e34c105b..5dfa3d03b 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/prefs/sections/NotifPrefs.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/prefs/sections/NotifPrefs.kt @@ -24,86 +24,66 @@ import com.pitchedapps.frost.prefs.PrefsBase import javax.inject.Inject interface NotifPrefs : PrefsBase { - var notificationKeywords: Set + var notificationKeywords: Set - var notificationsGeneral: Boolean + var notificationsGeneral: Boolean - var notificationAllAccounts: Boolean + var notificationAllAccounts: Boolean - var notificationsInstantMessages: Boolean + var notificationsInstantMessages: Boolean - var notificationsImAllAccounts: Boolean + var notificationsImAllAccounts: Boolean - var notificationVibrate: Boolean + var notificationVibrate: Boolean - var notificationSound: Boolean + var notificationSound: Boolean - var notificationRingtone: String + var notificationRingtone: String - var messageRingtone: String + var messageRingtone: String - var notificationLights: Boolean + var notificationLights: Boolean - var notificationFreq: Long + var notificationFreq: Long } -class NotifPrefsImpl @Inject internal constructor( - factory: KPrefFactory, - oldPrefs: OldPrefs, +class NotifPrefsImpl +@Inject +internal constructor( + factory: KPrefFactory, + oldPrefs: OldPrefs, ) : KPref("${BuildConfig.APPLICATION_ID}.prefs.notif", factory), NotifPrefs { - override var notificationKeywords: Set by kpref( - "notification_keywords", - oldPrefs.notificationKeywords /* mutableSetOf() */ - ) + override var notificationKeywords: Set by + kpref("notification_keywords", oldPrefs.notificationKeywords /* mutableSetOf() */) - override var notificationsGeneral: Boolean by kpref( - "notification_general", - oldPrefs.notificationsGeneral /* true */ - ) + override var notificationsGeneral: Boolean by + kpref("notification_general", oldPrefs.notificationsGeneral /* true */) - override var notificationAllAccounts: Boolean by kpref( - "notification_all_accounts", - oldPrefs.notificationAllAccounts /* true */ - ) + override var notificationAllAccounts: Boolean by + kpref("notification_all_accounts", oldPrefs.notificationAllAccounts /* true */) - override var notificationsInstantMessages: Boolean by kpref( - "notification_im", - oldPrefs.notificationsInstantMessages /* true */ - ) + override var notificationsInstantMessages: Boolean by + kpref("notification_im", oldPrefs.notificationsInstantMessages /* true */) - override var notificationsImAllAccounts: Boolean by kpref( - "notification_im_all_accounts", - oldPrefs.notificationsImAllAccounts /* false */ - ) + override var notificationsImAllAccounts: Boolean by + kpref("notification_im_all_accounts", oldPrefs.notificationsImAllAccounts /* false */) - override var notificationVibrate: Boolean by kpref( - "notification_vibrate", - oldPrefs.notificationVibrate /* true */ - ) + override var notificationVibrate: Boolean by + kpref("notification_vibrate", oldPrefs.notificationVibrate /* true */) - override var notificationSound: Boolean by kpref( - "notification_sound", - oldPrefs.notificationSound /* true */ - ) + override var notificationSound: Boolean by + kpref("notification_sound", oldPrefs.notificationSound /* true */) - override var notificationRingtone: String by kpref( - "notification_ringtone", - oldPrefs.notificationRingtone /* "" */ - ) + override var notificationRingtone: String by + kpref("notification_ringtone", oldPrefs.notificationRingtone /* "" */) - override var messageRingtone: String by kpref( - "message_ringtone", - oldPrefs.messageRingtone /* "" */ - ) + override var messageRingtone: String by + kpref("message_ringtone", oldPrefs.messageRingtone /* "" */) - override var notificationLights: Boolean by kpref( - "notification_lights", - oldPrefs.notificationLights /* true */ - ) + override var notificationLights: Boolean by + kpref("notification_lights", oldPrefs.notificationLights /* true */) - override var notificationFreq: Long by kpref( - "notification_freq", - oldPrefs.notificationFreq /* 15L */ - ) + override var notificationFreq: Long by + kpref("notification_freq", oldPrefs.notificationFreq /* 15L */) } diff --git a/app/src/main/kotlin/com/pitchedapps/frost/prefs/sections/ShowcasePrefs.kt b/app/src/main/kotlin/com/pitchedapps/frost/prefs/sections/ShowcasePrefs.kt index dce8b8983..fb5b6c853 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/prefs/sections/ShowcasePrefs.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/prefs/sections/ShowcasePrefs.kt @@ -23,12 +23,10 @@ import com.pitchedapps.frost.prefs.PrefsBase import javax.inject.Inject interface ShowcasePrefs : PrefsBase { - /** - * Check if this is the first time launching the web overlay; show snackbar if true - */ - val firstWebOverlay: Boolean + /** Check if this is the first time launching the web overlay; show snackbar if true */ + val firstWebOverlay: Boolean - val intro: Boolean + val intro: Boolean } /** @@ -36,11 +34,10 @@ interface ShowcasePrefs : PrefsBase { * * Showcase prefs that offer one time helpers to guide new users */ -class ShowcasePrefsImpl @Inject internal constructor( - factory: KPrefFactory -) : KPref("${BuildConfig.APPLICATION_ID}.showcase", factory), ShowcasePrefs { +class ShowcasePrefsImpl @Inject internal constructor(factory: KPrefFactory) : + KPref("${BuildConfig.APPLICATION_ID}.showcase", factory), ShowcasePrefs { - override val firstWebOverlay: Boolean by kprefSingle("first_web_overlay") + override val firstWebOverlay: Boolean by kprefSingle("first_web_overlay") - override val intro: Boolean by kprefSingle("intro_pages") + override val intro: Boolean by kprefSingle("intro_pages") } diff --git a/app/src/main/kotlin/com/pitchedapps/frost/prefs/sections/ThemePrefs.kt b/app/src/main/kotlin/com/pitchedapps/frost/prefs/sections/ThemePrefs.kt index b024b2d3c..59bbae029 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/prefs/sections/ThemePrefs.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/prefs/sections/ThemePrefs.kt @@ -24,56 +24,45 @@ import com.pitchedapps.frost.prefs.PrefsBase import javax.inject.Inject interface ThemePrefs : PrefsBase { - var theme: Int + var theme: Int - var customTextColor: Int + var customTextColor: Int - var customAccentColor: Int + var customAccentColor: Int - var customBackgroundColor: Int + var customBackgroundColor: Int - var customHeaderColor: Int + var customHeaderColor: Int - var customIconColor: Int + var customIconColor: Int - var tintNavBar: Boolean + var tintNavBar: Boolean } -class ThemePrefsImpl @Inject internal constructor( - factory: KPrefFactory, - oldPrefs: OldPrefs, +class ThemePrefsImpl +@Inject +internal constructor( + factory: KPrefFactory, + oldPrefs: OldPrefs, ) : KPref("${BuildConfig.APPLICATION_ID}.prefs.theme", factory), ThemePrefs { - /** - * Note that this is purely for the pref storage. Updating themes should use - * ThemeProvider - */ - override var theme: Int by kpref("theme", oldPrefs.theme /* 0 */) + /** Note that this is purely for the pref storage. Updating themes should use ThemeProvider */ + override var theme: Int by kpref("theme", oldPrefs.theme /* 0 */) - override var customTextColor: Int by kpref( - "color_text", - oldPrefs.customTextColor /* 0xffeceff1.toInt() */ - ) + override var customTextColor: Int by + kpref("color_text", oldPrefs.customTextColor /* 0xffeceff1.toInt() */) - override var customAccentColor: Int by kpref( - "color_accent", - oldPrefs.customAccentColor /* 0xff0288d1.toInt() */ - ) + override var customAccentColor: Int by + kpref("color_accent", oldPrefs.customAccentColor /* 0xff0288d1.toInt() */) - override var customBackgroundColor: Int by kpref( - "color_bg", - oldPrefs.customBackgroundColor /* 0xff212121.toInt() */ - ) + override var customBackgroundColor: Int by + kpref("color_bg", oldPrefs.customBackgroundColor /* 0xff212121.toInt() */) - override var customHeaderColor: Int by kpref( - "color_header", - oldPrefs.customHeaderColor /* 0xff01579b.toInt() */ - ) + override var customHeaderColor: Int by + kpref("color_header", oldPrefs.customHeaderColor /* 0xff01579b.toInt() */) - override var customIconColor: Int by kpref( - "color_icons", - oldPrefs.customIconColor /* 0xffeceff1.toInt() */ - ) + override var customIconColor: Int by + kpref("color_icons", oldPrefs.customIconColor /* 0xffeceff1.toInt() */) - override var tintNavBar: Boolean by kpref("tint_nav_bar", oldPrefs.tintNavBar /* true */) + override var tintNavBar: Boolean by kpref("tint_nav_bar", oldPrefs.tintNavBar /* true */) } diff --git a/app/src/main/kotlin/com/pitchedapps/frost/services/BaseJobService.kt b/app/src/main/kotlin/com/pitchedapps/frost/services/BaseJobService.kt index 0db08d0f1..33fefbf9f 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/services/BaseJobService.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/services/BaseJobService.kt @@ -20,32 +20,30 @@ import android.app.job.JobParameters import android.app.job.JobService import androidx.annotation.CallSuper import ca.allanwang.kau.utils.ContextHelper +import kotlin.coroutines.CoroutineContext import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job -import kotlin.coroutines.CoroutineContext abstract class BaseJobService : JobService(), CoroutineScope { - private lateinit var job: Job - override val coroutineContext: CoroutineContext - get() = ContextHelper.dispatcher + job + private lateinit var job: Job + override val coroutineContext: CoroutineContext + get() = ContextHelper.dispatcher + job - protected val startTime = System.currentTimeMillis() + protected val startTime = System.currentTimeMillis() - /** - * Note that if a job plans on running asynchronously, it should return true - */ - @CallSuper - override fun onStartJob(params: JobParameters?): Boolean { - job = Job() - return false - } + /** Note that if a job plans on running asynchronously, it should return true */ + @CallSuper + override fun onStartJob(params: JobParameters?): Boolean { + job = Job() + return false + } - @CallSuper - override fun onStopJob(params: JobParameters?): Boolean { - job.cancel() - return false - } + @CallSuper + override fun onStopJob(params: JobParameters?): Boolean { + job.cancel() + return false + } } /* diff --git a/app/src/main/kotlin/com/pitchedapps/frost/services/FrostNotifications.kt b/app/src/main/kotlin/com/pitchedapps/frost/services/FrostNotifications.kt index e050ad5a3..cc54e1fa6 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/services/FrostNotifications.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/services/FrostNotifications.kt @@ -60,283 +60,271 @@ import kotlin.math.abs private val _40_DP = 40.dpToPx private val pendingIntentFlagUpdateCurrent: Int - get() = PendingIntent.FLAG_UPDATE_CURRENT or - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) PendingIntent.FLAG_IMMUTABLE else 0 + get() = + PendingIntent.FLAG_UPDATE_CURRENT or + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) PendingIntent.FLAG_IMMUTABLE else 0 -/** - * Enum to handle notification creations - */ +/** Enum to handle notification creations */ enum class NotificationType( - val channelId: String, - private val overlayContext: OverlayContext, - private val fbItem: FbItem, - private val parser: FrostParser, - private val ringtoneProvider: (Prefs) -> String + val channelId: String, + private val overlayContext: OverlayContext, + private val fbItem: FbItem, + private val parser: FrostParser, + private val ringtoneProvider: (Prefs) -> String ) { + GENERAL( + NOTIF_CHANNEL_GENERAL, + OverlayContext.NOTIFICATION, + FbItem.NOTIFICATIONS, + NotifParser, + { it.notificationRingtone } + ), + MESSAGE( + NOTIF_CHANNEL_MESSAGES, + OverlayContext.MESSAGE, + FbItem.MESSAGES, + MessageParser, + { it.messageRingtone } + ); - GENERAL( - NOTIF_CHANNEL_GENERAL, - OverlayContext.NOTIFICATION, - FbItem.NOTIFICATIONS, - NotifParser, - { it.notificationRingtone } - ), + private val groupPrefix = "frost_${name.toLowerCase(Locale.CANADA)}" - MESSAGE( - NOTIF_CHANNEL_MESSAGES, - OverlayContext.MESSAGE, - FbItem.MESSAGES, - MessageParser, - { it.messageRingtone } - ); + /** Optional binder to return the request bundle builder */ + internal open fun bindRequest( + content: NotificationContent, + cookie: String + ): (BaseBundle.() -> Unit)? = null - private val groupPrefix = "frost_${name.toLowerCase(Locale.CANADA)}" + private fun bindRequest(intent: Intent, content: NotificationContent) { + val cookie = content.data.cookie ?: return + val binder = bindRequest(content, cookie) ?: return + val bundle = Bundle() + bundle.binder() + intent.putExtras(bundle) + } - /** - * Optional binder to return the request bundle builder - */ - internal open fun bindRequest( - content: NotificationContent, - cookie: String - ): (BaseBundle.() -> Unit)? = null - - private fun bindRequest(intent: Intent, content: NotificationContent) { - val cookie = content.data.cookie ?: return - val binder = bindRequest(content, cookie) ?: return - val bundle = Bundle() - bundle.binder() - intent.putExtras(bundle) + /** + * Get unread data from designated parser Display notifications for those after old epoch Save new + * epoch + * + * Returns the number of notifications generated, or -1 if an error occurred + */ + suspend fun fetch( + context: Context, + data: CookieEntity, + prefs: Prefs, + notifDao: NotificationDao + ): Int { + val response = + try { + parser.parse(data.cookie) + } catch (ignored: Exception) { + null + } + if (response == null) { + L.v { "$name notification data not found" } + return -1 } - /** - * Get unread data from designated parser - * Display notifications for those after old epoch - * Save new epoch - * - * Returns the number of notifications generated, - * or -1 if an error occurred - */ - suspend fun fetch( - context: Context, - data: CookieEntity, - prefs: Prefs, - notifDao: NotificationDao - ): Int { - val response = try { - parser.parse(data.cookie) - } catch (ignored: Exception) { - null - } - if (response == null) { - L.v { "$name notification data not found" } - return -1 - } - - /** - * Checks that the text doesn't contain any blacklisted keywords - */ - fun validText(text: String?): Boolean { - val t = text ?: return true - return prefs.notificationKeywords.none { - t.contains(it, true) - } - } - - val notifContents = response.data.getUnreadNotifications(data).filter { notif -> - validText(notif.title) && validText(notif.text) - } - if (notifContents.isEmpty()) return 0 - - val userId = data.id - val prevLatestEpoch = notifDao.latestEpoch(userId, channelId) - L.v { "Notif $name prev epoch $prevLatestEpoch" } - - if (!notifDao.saveNotifications(channelId, notifContents)) { - L.d { "Skip notifs for $name as saving failed" } - return -1 - } - - if (prevLatestEpoch == -1L && !BuildConfig.DEBUG) { - L.d { "Skipping first notification fetch" } - return 0 // do not notify the first time - } - - val newNotifContents = notifContents.filter { it.timestamp > prevLatestEpoch } - - if (newNotifContents.isEmpty()) { - L.d { "No new notifs found for $name" } - return 0 - } - - L.d { "${newNotifContents.size} new notifs found for $name" } - - val notifs = newNotifContents.map { createNotification(context, it) } - - frostEvent("Notifications", "Type" to name, "Count" to notifs.size) - if (notifs.size > 1) - summaryNotification(context, userId, notifs.size).notify(context) - val ringtone = ringtoneProvider(prefs) - notifs.forEachIndexed { i, notif -> - // Ring at most twice - notif.withAlert(context, i < 2, ringtone, prefs).notify(context) - } - return notifs.size + /** Checks that the text doesn't contain any blacklisted keywords */ + fun validText(text: String?): Boolean { + val t = text ?: return true + return prefs.notificationKeywords.none { t.contains(it, true) } } - fun debugNotification(context: Context, data: CookieEntity) { - val content = NotificationContent( - data, - System.currentTimeMillis(), - "https://github.com/AllanWang/Frost-for-Facebook", - "Debug Notif", - "Test 123", - System.currentTimeMillis() / 1000, - "https://www.iconexperience.com/_img/v_collection_png/256x256/shadow/dog.png", - false - ) - createNotification(context, content).notify(context) + val notifContents = + response.data.getUnreadNotifications(data).filter { notif -> + validText(notif.title) && validText(notif.text) + } + if (notifContents.isEmpty()) return 0 + + val userId = data.id + val prevLatestEpoch = notifDao.latestEpoch(userId, channelId) + L.v { "Notif $name prev epoch $prevLatestEpoch" } + + if (!notifDao.saveNotifications(channelId, notifContents)) { + L.d { "Skip notifs for $name as saving failed" } + return -1 } - /** - * Attach content related data to an intent - */ - fun putContentExtra(intent: Intent, content: NotificationContent): Intent { - // We will show the notification page for dependent urls. We can trigger a click next time - intent.data = - Uri.parse(if (content.href.isIndependent) content.href else FbItem.NOTIFICATIONS.url) - bindRequest(intent, content) - return intent + if (prevLatestEpoch == -1L && !BuildConfig.DEBUG) { + L.d { "Skipping first notification fetch" } + return 0 // do not notify the first time } - /** - * Create a generic content for the provided type and user id. - * No content related data is added - */ - fun createCommonIntent(context: Context, userId: Long): Intent { - val intent = Intent(context, FrostWebActivity::class.java) - intent.putExtra(ARG_USER_ID, userId) - overlayContext.put(intent) - return intent + val newNotifContents = notifContents.filter { it.timestamp > prevLatestEpoch } + + if (newNotifContents.isEmpty()) { + L.d { "No new notifs found for $name" } + return 0 } - /** - * Create and submit a new notification with the given [content] - */ - private fun createNotification( - context: Context, - content: NotificationContent - ): FrostNotification = - with(content) { - val intent = createCommonIntent(context, content.data.id) - putContentExtra(intent, content) - val group = "${groupPrefix}_${data.id}" - val pendingIntent = - PendingIntent.getActivity(context, 0, intent, pendingIntentFlagUpdateCurrent) - val notifBuilder = context.frostNotification(channelId) - .setContentTitle(title ?: context.string(R.string.frost_name)) - .setContentText(text) - .setContentIntent(pendingIntent) - .setCategory(Notification.CATEGORY_SOCIAL) - .setSubText(data.name) - .setGroup(group) + L.d { "${newNotifContents.size} new notifs found for $name" } - if (timestamp != -1L) notifBuilder.setWhen(timestamp * 1000) - L.v { "Notif load $content" } + val notifs = newNotifContents.map { createNotification(context, it) } - if (profileUrl != null) { - try { - val profileImg = GlideApp.with(context) - .asBitmap() - .load(profileUrl) - .transform(FrostGlide.circleCrop) - .submit(_40_DP, _40_DP) - .get() - notifBuilder.setLargeIcon(profileImg) - } catch (e: Exception) { - L.e { "Failed to get image $profileUrl" } - } - } + frostEvent("Notifications", "Type" to name, "Count" to notifs.size) + if (notifs.size > 1) summaryNotification(context, userId, notifs.size).notify(context) + val ringtone = ringtoneProvider(prefs) + notifs.forEachIndexed { i, notif -> + // Ring at most twice + notif.withAlert(context, i < 2, ringtone, prefs).notify(context) + } + return notifs.size + } - FrostNotification(group, notifId, notifBuilder) + fun debugNotification(context: Context, data: CookieEntity) { + val content = + NotificationContent( + data, + System.currentTimeMillis(), + "https://github.com/AllanWang/Frost-for-Facebook", + "Debug Notif", + "Test 123", + System.currentTimeMillis() / 1000, + "https://www.iconexperience.com/_img/v_collection_png/256x256/shadow/dog.png", + false + ) + createNotification(context, content).notify(context) + } + + /** Attach content related data to an intent */ + fun putContentExtra(intent: Intent, content: NotificationContent): Intent { + // We will show the notification page for dependent urls. We can trigger a click next time + intent.data = + Uri.parse(if (content.href.isIndependent) content.href else FbItem.NOTIFICATIONS.url) + bindRequest(intent, content) + return intent + } + + /** + * Create a generic content for the provided type and user id. No content related data is added + */ + fun createCommonIntent(context: Context, userId: Long): Intent { + val intent = Intent(context, FrostWebActivity::class.java) + intent.putExtra(ARG_USER_ID, userId) + overlayContext.put(intent) + return intent + } + + /** Create and submit a new notification with the given [content] */ + private fun createNotification( + context: Context, + content: NotificationContent + ): FrostNotification = + with(content) { + val intent = createCommonIntent(context, content.data.id) + putContentExtra(intent, content) + val group = "${groupPrefix}_${data.id}" + val pendingIntent = + PendingIntent.getActivity(context, 0, intent, pendingIntentFlagUpdateCurrent) + val notifBuilder = + context + .frostNotification(channelId) + .setContentTitle(title ?: context.string(R.string.frost_name)) + .setContentText(text) + .setContentIntent(pendingIntent) + .setCategory(Notification.CATEGORY_SOCIAL) + .setSubText(data.name) + .setGroup(group) + + if (timestamp != -1L) notifBuilder.setWhen(timestamp * 1000) + L.v { "Notif load $content" } + + if (profileUrl != null) { + try { + val profileImg = + GlideApp.with(context) + .asBitmap() + .load(profileUrl) + .transform(FrostGlide.circleCrop) + .submit(_40_DP, _40_DP) + .get() + notifBuilder.setLargeIcon(profileImg) + } catch (e: Exception) { + L.e { "Failed to get image $profileUrl" } } + } - /** - * Create a summary notification to wrap the previous ones - * This will always produce sound, vibration, and lights based on preferences - * and will only show if we have at least 2 notifications - */ - private fun summaryNotification(context: Context, userId: Long, count: Int): FrostNotification { - val intent = createCommonIntent(context, userId) - intent.data = Uri.parse(fbItem.url) - val group = "${groupPrefix}_$userId" - val pendingIntent = - PendingIntent.getActivity(context, 0, intent, pendingIntentFlagUpdateCurrent) - val notifBuilder = context.frostNotification(channelId) - .setContentTitle(context.string(R.string.frost_name)) - .setContentText("$count ${context.string(fbItem.titleId)}") - .setGroup(group) - .setGroupSummary(true) - .setContentIntent(pendingIntent) - .setCategory(Notification.CATEGORY_SOCIAL) - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - notifBuilder.setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_CHILDREN) - } - - return FrostNotification(group, 1, notifBuilder) + FrostNotification(group, notifId, notifBuilder) } + + /** + * Create a summary notification to wrap the previous ones This will always produce sound, + * vibration, and lights based on preferences and will only show if we have at least 2 + * notifications + */ + private fun summaryNotification(context: Context, userId: Long, count: Int): FrostNotification { + val intent = createCommonIntent(context, userId) + intent.data = Uri.parse(fbItem.url) + val group = "${groupPrefix}_$userId" + val pendingIntent = + PendingIntent.getActivity(context, 0, intent, pendingIntentFlagUpdateCurrent) + val notifBuilder = + context + .frostNotification(channelId) + .setContentTitle(context.string(R.string.frost_name)) + .setContentText("$count ${context.string(fbItem.titleId)}") + .setGroup(group) + .setGroupSummary(true) + .setContentIntent(pendingIntent) + .setCategory(Notification.CATEGORY_SOCIAL) + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + notifBuilder.setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_CHILDREN) + } + + return FrostNotification(group, 1, notifBuilder) + } } -/** - * Notification data holder - */ +/** Notification data holder */ data class NotificationContent( - // TODO replace data with userId? - val data: CookieEntity, - val id: Long, - val href: String, - val title: String? = null, // defaults to frost title - val text: String, - val timestamp: Long, - val profileUrl: String?, - val unread: Boolean + // TODO replace data with userId? + val data: CookieEntity, + val id: Long, + val href: String, + val title: String? = null, // defaults to frost title + val text: String, + val timestamp: Long, + val profileUrl: String?, + val unread: Boolean ) { - val notifId = abs(id.toInt()) + val notifId = abs(id.toInt()) } /** - * Wrapper for a complete notification builder and identifier - * which can be immediately notified when given a [Context] + * Wrapper for a complete notification builder and identifier which can be immediately notified when + * given a [Context] */ data class FrostNotification( - private val tag: String, - private val id: Int, - val notif: NotificationCompat.Builder + private val tag: String, + private val id: Int, + val notif: NotificationCompat.Builder ) { - fun withAlert( - context: Context, - enable: Boolean, - ringtone: String, - prefs: Prefs - ): FrostNotification { - notif.setFrostAlert(context, enable, ringtone, prefs) - return this - } + fun withAlert( + context: Context, + enable: Boolean, + ringtone: String, + prefs: Prefs + ): FrostNotification { + notif.setFrostAlert(context, enable, ringtone, prefs) + return this + } - fun notify(context: Context) = - NotificationManagerCompat.from(context).notify(tag, id, notif.build()) + fun notify(context: Context) = + NotificationManagerCompat.from(context).notify(tag, id, notif.build()) } fun Context.scheduleNotificationsFromPrefs(prefs: Prefs): Boolean { - val shouldSchedule = prefs.hasNotifications - return if (shouldSchedule) scheduleNotifications(prefs.notificationFreq) - else scheduleNotifications(-1) + val shouldSchedule = prefs.hasNotifications + return if (shouldSchedule) scheduleNotifications(prefs.notificationFreq) + else scheduleNotifications(-1) } fun Context.scheduleNotifications(minutes: Long): Boolean = - scheduleJob(NOTIFICATION_PERIODIC_JOB, minutes) + scheduleJob(NOTIFICATION_PERIODIC_JOB, minutes) -fun Context.fetchNotifications(): Boolean = - fetchJob(NOTIFICATION_JOB_NOW) +fun Context.fetchNotifications(): Boolean = fetchJob(NOTIFICATION_JOB_NOW) diff --git a/app/src/main/kotlin/com/pitchedapps/frost/services/NotificationService.kt b/app/src/main/kotlin/com/pitchedapps/frost/services/NotificationService.kt index 01f52caa1..5c29d0a99 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/services/NotificationService.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/services/NotificationService.kt @@ -30,122 +30,116 @@ import com.pitchedapps.frost.utils.L import com.pitchedapps.frost.utils.frostEvent import com.pitchedapps.frost.widgets.NotificationWidget import dagger.hilt.android.AndroidEntryPoint +import javax.inject.Inject import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import kotlinx.coroutines.yield -import javax.inject.Inject /** * Created by Allan Wang on 2017-06-14. * - * Service to manage notifications - * Will periodically check through all accounts in the db and send notifications when appropriate + * Service to manage notifications Will periodically check through all accounts in the db and send + * notifications when appropriate * * All fetching is done through parsers */ @AndroidEntryPoint class NotificationService : BaseJobService() { - @Inject - lateinit var prefs: Prefs + @Inject lateinit var prefs: Prefs - @Inject - lateinit var notifDao: NotificationDao + @Inject lateinit var notifDao: NotificationDao - @Inject - lateinit var cookieDao: CookieDao + @Inject lateinit var cookieDao: CookieDao - override fun onStopJob(params: JobParameters?): Boolean { - super.onStopJob(params) - prepareFinish(true) - return false + override fun onStopJob(params: JobParameters?): Boolean { + super.onStopJob(params) + prepareFinish(true) + return false + } + + private var preparedFinish = false + + private fun prepareFinish(abrupt: Boolean) { + if (preparedFinish) return + preparedFinish = true + val time = System.currentTimeMillis() - startTime + L.i { + "Notification service has ${if (abrupt) "finished abruptly" else "finished"} in $time ms" + } + frostEvent( + "NotificationTime", + "Type" to (if (abrupt) "Service force stop" else "Service"), + "IM Included" to prefs.notificationsInstantMessages, + "Duration" to time + ) + } + + override fun onStartJob(params: JobParameters?): Boolean { + super.onStartJob(params) + L.i { "Fetching notifications" } + launch { + try { + sendNotifications(params) + } finally { + if (!isActive) prepareFinish(false) + jobFinished(params, false) + } + } + return true + } + + private suspend fun sendNotifications(params: JobParameters?): Unit = + withContext(Dispatchers.Default) { + val currentId = prefs.userId + val cookies = cookieDao.selectAll() + yield() + val jobId = params?.extras?.getInt(NOTIFICATION_PARAM_ID, -1) ?: -1 + var notifCount = 0 + for (cookie in cookies) { + yield() + val current = cookie.id == currentId + if (prefs.notificationsGeneral && (current || prefs.notificationAllAccounts)) + notifCount += fetch(jobId, NotificationType.GENERAL, cookie) + if (prefs.notificationsInstantMessages && (current || prefs.notificationsImAllAccounts)) + notifCount += fetch(jobId, NotificationType.MESSAGE, cookie) + } + + L.i { "Sent $notifCount notifications" } + if (notifCount == 0 && jobId == NOTIFICATION_JOB_NOW) + generalNotification(665, R.string.no_new_notifications, BuildConfig.DEBUG) + if (notifCount > 0) { + NotificationWidget.forceUpdate(this@NotificationService) + } } - private var preparedFinish = false - - private fun prepareFinish(abrupt: Boolean) { - if (preparedFinish) - return - preparedFinish = true - val time = System.currentTimeMillis() - startTime - L.i { "Notification service has ${if (abrupt) "finished abruptly" else "finished"} in $time ms" } - frostEvent( - "NotificationTime", - "Type" to (if (abrupt) "Service force stop" else "Service"), - "IM Included" to prefs.notificationsInstantMessages, - "Duration" to time - ) + /** + * Implemented fetch to also notify when an error occurs Also normalized the output to return the + * number of notifications received + */ + private suspend fun fetch(jobId: Int, type: NotificationType, cookie: CookieEntity): Int { + val count = type.fetch(this, cookie, prefs, notifDao) + if (count < 0) { + if (jobId == NOTIFICATION_JOB_NOW) + generalNotification(666, R.string.error_notification, BuildConfig.DEBUG) + return 0 } + return count + } - override fun onStartJob(params: JobParameters?): Boolean { - super.onStartJob(params) - L.i { "Fetching notifications" } - launch { - try { - sendNotifications(params) - } finally { - if (!isActive) - prepareFinish(false) - jobFinished(params, false) - } - } - return true - } + private fun logNotif(text: String): NotificationContent? { + L.eThrow("NotificationService: $text") + return null + } - private suspend fun sendNotifications(params: JobParameters?): Unit = - withContext(Dispatchers.Default) { - val currentId = prefs.userId - val cookies = cookieDao.selectAll() - yield() - val jobId = params?.extras?.getInt(NOTIFICATION_PARAM_ID, -1) ?: -1 - var notifCount = 0 - for (cookie in cookies) { - yield() - val current = cookie.id == currentId - if (prefs.notificationsGeneral && - (current || prefs.notificationAllAccounts) - ) - notifCount += fetch(jobId, NotificationType.GENERAL, cookie) - if (prefs.notificationsInstantMessages && - (current || prefs.notificationsImAllAccounts) - ) - notifCount += fetch(jobId, NotificationType.MESSAGE, cookie) - } - - L.i { "Sent $notifCount notifications" } - if (notifCount == 0 && jobId == NOTIFICATION_JOB_NOW) - generalNotification(665, R.string.no_new_notifications, BuildConfig.DEBUG) - if (notifCount > 0) { - NotificationWidget.forceUpdate(this@NotificationService) - } - } - - /** - * Implemented fetch to also notify when an error occurs - * Also normalized the output to return the number of notifications received - */ - private suspend fun fetch(jobId: Int, type: NotificationType, cookie: CookieEntity): Int { - val count = type.fetch(this, cookie, prefs, notifDao) - if (count < 0) { - if (jobId == NOTIFICATION_JOB_NOW) - generalNotification(666, R.string.error_notification, BuildConfig.DEBUG) - return 0 - } - return count - } - - private fun logNotif(text: String): NotificationContent? { - L.eThrow("NotificationService: $text") - return null - } - - private fun generalNotification(id: Int, textRes: Int, withDefaults: Boolean) { - val notifBuilder = frostNotification(NOTIF_CHANNEL_GENERAL) - .setFrostAlert(this, withDefaults, prefs.notificationRingtone, prefs) - .setContentTitle(string(R.string.frost_name)) - .setContentText(string(textRes)) - NotificationManagerCompat.from(this).notify(id, notifBuilder.build()) - } + private fun generalNotification(id: Int, textRes: Int, withDefaults: Boolean) { + val notifBuilder = + frostNotification(NOTIF_CHANNEL_GENERAL) + .setFrostAlert(this, withDefaults, prefs.notificationRingtone, prefs) + .setContentTitle(string(R.string.frost_name)) + .setContentText(string(textRes)) + NotificationManagerCompat.from(this).notify(id, notifBuilder.build()) + } } diff --git a/app/src/main/kotlin/com/pitchedapps/frost/services/NotificationUtils.kt b/app/src/main/kotlin/com/pitchedapps/frost/services/NotificationUtils.kt index 0bb9d2544..12562f1eb 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/services/NotificationUtils.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/services/NotificationUtils.kt @@ -36,84 +36,76 @@ import com.pitchedapps.frost.prefs.Prefs import com.pitchedapps.frost.utils.L import com.pitchedapps.frost.utils.frostUri -/** - * Created by Allan Wang on 07/04/18. - */ +/** Created by Allan Wang on 07/04/18. */ const val NOTIF_CHANNEL_GENERAL = "general" const val NOTIF_CHANNEL_MESSAGES = "messages" fun setupNotificationChannels(c: Context, themeProvider: ThemeProvider) { - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return - val manager = c.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager - val appName = c.string(R.string.frost_name) - val msg = c.string(R.string.messages) - manager.createNotificationChannel(NOTIF_CHANNEL_GENERAL, appName, themeProvider) - manager.createNotificationChannel(NOTIF_CHANNEL_MESSAGES, "$appName: $msg", themeProvider) - manager.notificationChannels - .filter { - it.id != NOTIF_CHANNEL_GENERAL && - it.id != NOTIF_CHANNEL_MESSAGES - } - .forEach { manager.deleteNotificationChannel(it.id) } - L.d { "Created notification channels: ${manager.notificationChannels.size} channels, ${manager.notificationChannelGroups.size} groups" } + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return + val manager = c.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + val appName = c.string(R.string.frost_name) + val msg = c.string(R.string.messages) + manager.createNotificationChannel(NOTIF_CHANNEL_GENERAL, appName, themeProvider) + manager.createNotificationChannel(NOTIF_CHANNEL_MESSAGES, "$appName: $msg", themeProvider) + manager.notificationChannels + .filter { it.id != NOTIF_CHANNEL_GENERAL && it.id != NOTIF_CHANNEL_MESSAGES } + .forEach { manager.deleteNotificationChannel(it.id) } + L.d { + "Created notification channels: ${manager.notificationChannels.size} channels, ${manager.notificationChannelGroups.size} groups" + } } @RequiresApi(Build.VERSION_CODES.O) private fun NotificationManager.createNotificationChannel( - id: String, - name: String, - themeProvider: ThemeProvider + id: String, + name: String, + themeProvider: ThemeProvider ): NotificationChannel { - val channel = NotificationChannel( - id, - name, NotificationManager.IMPORTANCE_DEFAULT - ) - channel.enableLights(true) - channel.lightColor = themeProvider.accentColor - channel.lockscreenVisibility = Notification.VISIBILITY_PUBLIC - createNotificationChannel(channel) - return channel + val channel = NotificationChannel(id, name, NotificationManager.IMPORTANCE_DEFAULT) + channel.enableLights(true) + channel.lightColor = themeProvider.accentColor + channel.lockscreenVisibility = Notification.VISIBILITY_PUBLIC + createNotificationChannel(channel) + return channel } fun Context.frostNotification(id: String) = - NotificationCompat.Builder(this, id) - .apply { - setSmallIcon(R.drawable.frost_f_24) - setAutoCancel(true) - setOnlyAlertOnce(true) - setStyle(NotificationCompat.BigTextStyle()) - color = color(R.color.frost_notification_accent) - } + NotificationCompat.Builder(this, id).apply { + setSmallIcon(R.drawable.frost_f_24) + setAutoCancel(true) + setOnlyAlertOnce(true) + setStyle(NotificationCompat.BigTextStyle()) + color = color(R.color.frost_notification_accent) + } /** - * Dictates whether a notification should have sound/vibration/lights or not - * Delegates to channels if Android O and up - * Otherwise uses our provided preferences + * Dictates whether a notification should have sound/vibration/lights or not Delegates to channels + * if Android O and up Otherwise uses our provided preferences */ fun NotificationCompat.Builder.setFrostAlert( - context: Context, - enable: Boolean, - ringtone: String, - prefs: Prefs + context: Context, + enable: Boolean, + ringtone: String, + prefs: Prefs ): NotificationCompat.Builder { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - setGroupAlertBehavior( - if (enable) NotificationCompat.GROUP_ALERT_CHILDREN - else NotificationCompat.GROUP_ALERT_SUMMARY - ) - } else if (!enable) { - setDefaults(0) - } else { - var defaults = 0 - if (prefs.notificationVibrate) defaults = defaults or Notification.DEFAULT_VIBRATE - if (prefs.notificationSound) { - if (ringtone.isNotBlank()) setSound(context.frostUri(ringtone)) - else defaults = defaults or Notification.DEFAULT_SOUND - } - if (prefs.notificationLights) defaults = defaults or Notification.DEFAULT_LIGHTS - setDefaults(defaults) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + setGroupAlertBehavior( + if (enable) NotificationCompat.GROUP_ALERT_CHILDREN + else NotificationCompat.GROUP_ALERT_SUMMARY + ) + } else if (!enable) { + setDefaults(0) + } else { + var defaults = 0 + if (prefs.notificationVibrate) defaults = defaults or Notification.DEFAULT_VIBRATE + if (prefs.notificationSound) { + if (ringtone.isNotBlank()) setSound(context.frostUri(ringtone)) + else defaults = defaults or Notification.DEFAULT_SOUND } - return this + if (prefs.notificationLights) defaults = defaults or Notification.DEFAULT_LIGHTS + setDefaults(defaults) + } + return this } /* @@ -125,48 +117,47 @@ fun NotificationCompat.Builder.setFrostAlert( const val NOTIFICATION_PARAM_ID = "notif_param_id" fun JobInfo.Builder.setExtras(id: Int): JobInfo.Builder { - val bundle = PersistableBundle() - bundle.putInt(NOTIFICATION_PARAM_ID, id) - return setExtras(bundle) + val bundle = PersistableBundle() + bundle.putInt(NOTIFICATION_PARAM_ID, id) + return setExtras(bundle) } /** - * interval is # of min, which must be at least 15 - * returns false if an error occurs; true otherwise + * interval is # of min, which must be at least 15 returns false if an error occurs; true otherwise */ inline fun Context.scheduleJob(id: Int, minutes: Long): Boolean { - val scheduler = getSystemService(Context.JOB_SCHEDULER_SERVICE) as JobScheduler - scheduler.cancel(id) - if (minutes < 0L) return true - val serviceComponent = ComponentName(this, T::class.java) - val builder = JobInfo.Builder(id, serviceComponent) - .setPeriodic(minutes * 60000) - .setExtras(id) - .setPersisted(true) - .setRequiredNetworkType(JobInfo.NETWORK_TYPE_ANY) // TODO add options - val result = scheduler.schedule(builder.build()) - if (result <= 0) { - L.eThrow("${T::class.java.simpleName} scheduler failed") - return false - } - return true + val scheduler = getSystemService(Context.JOB_SCHEDULER_SERVICE) as JobScheduler + scheduler.cancel(id) + if (minutes < 0L) return true + val serviceComponent = ComponentName(this, T::class.java) + val builder = + JobInfo.Builder(id, serviceComponent) + .setPeriodic(minutes * 60000) + .setExtras(id) + .setPersisted(true) + .setRequiredNetworkType(JobInfo.NETWORK_TYPE_ANY) // TODO add options + val result = scheduler.schedule(builder.build()) + if (result <= 0) { + L.eThrow("${T::class.java.simpleName} scheduler failed") + return false + } + return true } -/** - * Run notification job right now - */ +/** Run notification job right now */ inline fun Context.fetchJob(id: Int): Boolean { - val scheduler = getSystemService(Context.JOB_SCHEDULER_SERVICE) as JobScheduler - val serviceComponent = ComponentName(this, T::class.java) - val builder = JobInfo.Builder(id, serviceComponent) - .setMinimumLatency(0L) - .setExtras(id) - .setOverrideDeadline(2000L) - .setRequiredNetworkType(JobInfo.NETWORK_TYPE_ANY) - val result = scheduler.schedule(builder.build()) - if (result <= 0) { - L.eThrow("${T::class.java.simpleName} instant scheduler failed") - return false - } - return true + val scheduler = getSystemService(Context.JOB_SCHEDULER_SERVICE) as JobScheduler + val serviceComponent = ComponentName(this, T::class.java) + val builder = + JobInfo.Builder(id, serviceComponent) + .setMinimumLatency(0L) + .setExtras(id) + .setOverrideDeadline(2000L) + .setRequiredNetworkType(JobInfo.NETWORK_TYPE_ANY) + val result = scheduler.schedule(builder.build()) + if (result <= 0) { + L.eThrow("${T::class.java.simpleName} instant scheduler failed") + return false + } + return true } diff --git a/app/src/main/kotlin/com/pitchedapps/frost/services/UpdateReceiver.kt b/app/src/main/kotlin/com/pitchedapps/frost/services/UpdateReceiver.kt index 91a60d90e..ca4aef3dd 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/services/UpdateReceiver.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/services/UpdateReceiver.kt @@ -32,12 +32,11 @@ import javax.inject.Inject @AndroidEntryPoint class UpdateReceiver : BroadcastReceiver() { - @Inject - lateinit var prefs: Prefs + @Inject lateinit var prefs: Prefs - override fun onReceive(context: Context, intent: Intent) { - if (intent.action != Intent.ACTION_MY_PACKAGE_REPLACED) return - L.d { "Frost has updated" } - context.scheduleNotifications(prefs.notificationFreq) // Update notifications - } + override fun onReceive(context: Context, intent: Intent) { + if (intent.action != Intent.ACTION_MY_PACKAGE_REPLACED) return + L.d { "Frost has updated" } + context.scheduleNotifications(prefs.notificationFreq) // Update notifications + } } diff --git a/app/src/main/kotlin/com/pitchedapps/frost/settings/Appearance.kt b/app/src/main/kotlin/com/pitchedapps/frost/settings/Appearance.kt index 34f1ba1c8..186524b4c 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/settings/Appearance.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/settings/Appearance.kt @@ -35,173 +35,170 @@ import com.pitchedapps.frost.utils.frostSnackbar import com.pitchedapps.frost.utils.launchTabCustomizerActivity import com.pitchedapps.frost.views.KPrefTextSeekbar -/** - * Created by Allan Wang on 2017-06-29. - */ +/** Created by Allan Wang on 2017-06-29. */ fun SettingsActivity.getAppearancePrefs(): KPrefAdapterBuilder.() -> Unit = { + header(R.string.theme_customization) - header(R.string.theme_customization) - - text(R.string.theme, prefs::theme, { themeProvider.setTheme(it) }) { - onClick = { - materialDialog { - title(R.string.theme) - listItemsSingleChoice( - items = Theme.values().map { string(it.textRes) }, - initialSelection = item.pref - ) { _, index, _ -> - if (item.pref != index) { - item.pref = index - shouldRestartMain() - reload() - activityThemer.setFrostTheme(forceTransparent = true) - themeExterior() - invalidateOptionsMenu() - frostEvent("Theme", "Count" to Theme(index).name) - } - } - } - } - textGetter = { - string(Theme(it).textRes) - } - } - - fun KPrefColorPicker.KPrefColorContract.dependsOnCustom() { - enabler = themeProvider::isCustomTheme - onDisabledClick = { frostSnackbar(R.string.requires_custom_theme, themeProvider) } - allowCustom = true - } - - fun invalidateCustomTheme() { - themeProvider.reset() - } - - colorPicker( - R.string.text_color, prefs::customTextColor, - { - prefs.customTextColor = it - reload() - invalidateCustomTheme() + text(R.string.theme, prefs::theme, { themeProvider.setTheme(it) }) { + onClick = { + materialDialog { + title(R.string.theme) + listItemsSingleChoice( + items = Theme.values().map { string(it.textRes) }, + initialSelection = item.pref + ) { _, index, _ -> + if (item.pref != index) { + item.pref = index shouldRestartMain() - } - ) { - dependsOnCustom() - allowCustomAlpha = false - } - - colorPicker( - R.string.accent_color, prefs::customAccentColor, - { - prefs.customAccentColor = it reload() - invalidateCustomTheme() - shouldRestartMain() - } - ) { - dependsOnCustom() - allowCustomAlpha = false - } - - colorPicker( - R.string.background_color, prefs::customBackgroundColor, - { - prefs.customBackgroundColor = it - bgCanvas.ripple(it, duration = 500L) - invalidateCustomTheme() activityThemer.setFrostTheme(forceTransparent = true) - shouldRestartMain() - } - ) { - dependsOnCustom() - allowCustomAlpha = true - } - - colorPicker( - R.string.header_color, prefs::customHeaderColor, - { - prefs.customHeaderColor = it - frostNavigationBar(prefs, themeProvider) - toolbarCanvas.ripple(it, RippleCanvas.MIDDLE, RippleCanvas.END, duration = 500L) - reload() - shouldRestartMain() - } - ) { - dependsOnCustom() - allowCustomAlpha = true - } - - colorPicker( - R.string.icon_color, prefs::customIconColor, - { - prefs.customIconColor = it + themeExterior() invalidateOptionsMenu() + frostEvent("Theme", "Count" to Theme(index).name) + } + } + } + } + textGetter = { string(Theme(it).textRes) } + } + + fun KPrefColorPicker.KPrefColorContract.dependsOnCustom() { + enabler = themeProvider::isCustomTheme + onDisabledClick = { frostSnackbar(R.string.requires_custom_theme, themeProvider) } + allowCustom = true + } + + fun invalidateCustomTheme() { + themeProvider.reset() + } + + colorPicker( + R.string.text_color, + prefs::customTextColor, + { + prefs.customTextColor = it + reload() + invalidateCustomTheme() + shouldRestartMain() + } + ) { + dependsOnCustom() + allowCustomAlpha = false + } + + colorPicker( + R.string.accent_color, + prefs::customAccentColor, + { + prefs.customAccentColor = it + reload() + invalidateCustomTheme() + shouldRestartMain() + } + ) { + dependsOnCustom() + allowCustomAlpha = false + } + + colorPicker( + R.string.background_color, + prefs::customBackgroundColor, + { + prefs.customBackgroundColor = it + bgCanvas.ripple(it, duration = 500L) + invalidateCustomTheme() + activityThemer.setFrostTheme(forceTransparent = true) + shouldRestartMain() + } + ) { + dependsOnCustom() + allowCustomAlpha = true + } + + colorPicker( + R.string.header_color, + prefs::customHeaderColor, + { + prefs.customHeaderColor = it + frostNavigationBar(prefs, themeProvider) + toolbarCanvas.ripple(it, RippleCanvas.MIDDLE, RippleCanvas.END, duration = 500L) + reload() + shouldRestartMain() + } + ) { + dependsOnCustom() + allowCustomAlpha = true + } + + colorPicker( + R.string.icon_color, + prefs::customIconColor, + { + prefs.customIconColor = it + invalidateOptionsMenu() + shouldRestartMain() + } + ) { + dependsOnCustom() + allowCustomAlpha = false + } + + header(R.string.global_customization) + + text( + R.string.main_activity_layout, + prefs::mainActivityLayoutType, + { prefs.mainActivityLayoutType = it } + ) { + textGetter = { string(prefs.mainActivityLayout.titleRes) } + onClick = { + materialDialog { + title(R.string.main_activity_layout_desc) + listItemsSingleChoice( + items = MainActivityLayout.values.map { string(it.titleRes) }, + initialSelection = item.pref + ) { _, index, _ -> + if (item.pref != index) { + item.pref = index shouldRestartMain() + frostEvent("Main Layout", "Type" to MainActivityLayout(index).name) + } } - ) { - dependsOnCustom() - allowCustomAlpha = false + } } + } - header(R.string.global_customization) + plainText(R.string.main_tabs) { + descRes = R.string.main_tabs_desc + onClick = { launchTabCustomizerActivity() } + } - text( - R.string.main_activity_layout, - prefs::mainActivityLayoutType, - { prefs.mainActivityLayoutType = it } - ) { - textGetter = { string(prefs.mainActivityLayout.titleRes) } - onClick = { - materialDialog { - title(R.string.main_activity_layout_desc) - listItemsSingleChoice( - items = MainActivityLayout.values.map { string(it.titleRes) }, - initialSelection = item.pref - ) { _, index, _ -> - if (item.pref != index) { - item.pref = index - shouldRestartMain() - frostEvent("Main Layout", "Type" to MainActivityLayout(index).name) - } - } - } - } + checkbox( + R.string.tint_nav, + prefs::tintNavBar, + { + prefs.tintNavBar = it + frostNavigationBar(prefs, themeProvider) + setFrostResult(REQUEST_NAV) } + ) { + descRes = R.string.tint_nav_desc + } - plainText(R.string.main_tabs) { - descRes = R.string.main_tabs_desc - onClick = { launchTabCustomizerActivity() } - } - - checkbox( - R.string.tint_nav, prefs::tintNavBar, - { - prefs.tintNavBar = it - frostNavigationBar(prefs, themeProvider) - setFrostResult(REQUEST_NAV) - } - ) { - descRes = R.string.tint_nav_desc - } - - list.add( - KPrefTextSeekbar( - KPrefSeekbar.KPrefSeekbarBuilder( - globalOptions, - R.string.web_text_scaling, prefs::webTextScaling - ) { - prefs.webTextScaling = it - setFrostResult(REQUEST_TEXT_ZOOM) - } - ) + list.add( + KPrefTextSeekbar( + KPrefSeekbar.KPrefSeekbarBuilder( + globalOptions, + R.string.web_text_scaling, + prefs::webTextScaling + ) { + prefs.webTextScaling = it + setFrostResult(REQUEST_TEXT_ZOOM) + } ) + ) - checkbox( - R.string.enforce_black_media_bg, prefs::blackMediaBg, - { - prefs.blackMediaBg = it - } - ) { - descRes = R.string.enforce_black_media_bg_desc - } + checkbox(R.string.enforce_black_media_bg, prefs::blackMediaBg, { prefs.blackMediaBg = it }) { + descRes = R.string.enforce_black_media_bg_desc + } } diff --git a/app/src/main/kotlin/com/pitchedapps/frost/settings/Behaviour.kt b/app/src/main/kotlin/com/pitchedapps/frost/settings/Behaviour.kt index a9d997630..ce0e63d38 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/settings/Behaviour.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/settings/Behaviour.kt @@ -20,76 +20,86 @@ import ca.allanwang.kau.kpref.activity.KPrefAdapterBuilder import com.pitchedapps.frost.R import com.pitchedapps.frost.activities.SettingsActivity -/** - * Created by Allan Wang on 2017-06-30. - */ +/** Created by Allan Wang on 2017-06-30. */ fun SettingsActivity.getBehaviourPrefs(): KPrefAdapterBuilder.() -> Unit = { + checkbox(R.string.auto_refresh_feed, prefs::autoRefreshFeed, { prefs.autoRefreshFeed = it }) { + descRes = R.string.auto_refresh_feed_desc + } - checkbox(R.string.auto_refresh_feed, prefs::autoRefreshFeed, { prefs.autoRefreshFeed = it }) { - descRes = R.string.auto_refresh_feed_desc + checkbox( + R.string.fancy_animations, + prefs::animate, + { + prefs.animate = it + animate = it } + ) { + descRes = R.string.fancy_animations_desc + } - checkbox(R.string.fancy_animations, prefs::animate, { prefs.animate = it; animate = it }) { - descRes = R.string.fancy_animations_desc + checkbox( + R.string.overlay_swipe, + prefs::overlayEnabled, + { + prefs.overlayEnabled = it + shouldRefreshMain() } + ) { + descRes = R.string.overlay_swipe_desc + } - checkbox( - R.string.overlay_swipe, - prefs::overlayEnabled, - { prefs.overlayEnabled = it; shouldRefreshMain() } - ) { - descRes = R.string.overlay_swipe_desc + checkbox( + R.string.overlay_full_screen_swipe, + prefs::overlayFullScreenSwipe, + { prefs.overlayFullScreenSwipe = it } + ) { + descRes = R.string.overlay_full_screen_swipe_desc + } + + checkbox( + R.string.open_links_in_default, + prefs::linksInDefaultApp, + { prefs.linksInDefaultApp = it } + ) { + descRes = R.string.open_links_in_default_desc + } + + checkbox(R.string.viewpager_swipe, prefs::viewpagerSwipe, { prefs.viewpagerSwipe = it }) { + descRes = R.string.viewpager_swipe_desc + } + + checkbox( + R.string.force_message_bottom, + prefs::messageScrollToBottom, + { prefs.messageScrollToBottom = it } + ) { + descRes = R.string.force_message_bottom_desc + } + + checkbox( + R.string.auto_expand_text_box, + prefs::autoExpandTextBox, + { + prefs.autoExpandTextBox = it + shouldRefreshMain() } + ) { + descRes = R.string.auto_expand_text_box_desc + } - checkbox( - R.string.overlay_full_screen_swipe, - prefs::overlayFullScreenSwipe, - { prefs.overlayFullScreenSwipe = it } - ) { - descRes = R.string.overlay_full_screen_swipe_desc - } + checkbox(R.string.enable_pip, prefs::enablePip, { prefs.enablePip = it }) { + descRes = R.string.enable_pip_desc + } - checkbox( - R.string.open_links_in_default, - prefs::linksInDefaultApp, - { prefs.linksInDefaultApp = it } - ) { - descRes = R.string.open_links_in_default_desc - } + // Not available for desktop user agent for now + // plainText(R.string.autoplay_settings) { + // descRes = R.string.autoplay_settings_desc + // onClick = { + // launchWebOverlay("${FB_URL_BASE}settings/videos/") + // } + // } - checkbox(R.string.viewpager_swipe, prefs::viewpagerSwipe, { prefs.viewpagerSwipe = it }) { - descRes = R.string.viewpager_swipe_desc - } - - checkbox( - R.string.force_message_bottom, - prefs::messageScrollToBottom, - { prefs.messageScrollToBottom = it } - ) { - descRes = R.string.force_message_bottom_desc - } - - checkbox( - R.string.auto_expand_text_box, - prefs::autoExpandTextBox, - { prefs.autoExpandTextBox = it; shouldRefreshMain() } - ) { - descRes = R.string.auto_expand_text_box_desc - } - - checkbox(R.string.enable_pip, prefs::enablePip, { prefs.enablePip = it }) { - descRes = R.string.enable_pip_desc - } - - // Not available for desktop user agent for now -// plainText(R.string.autoplay_settings) { -// descRes = R.string.autoplay_settings_desc -// onClick = { -// launchWebOverlay("${FB_URL_BASE}settings/videos/") -// } -// } - - checkbox(R.string.exit_confirmation, prefs::exitConfirmation, { prefs.exitConfirmation = it }) { - descRes = R.string.exit_confirmation_desc - } + checkbox(R.string.exit_confirmation, prefs::exitConfirmation, { prefs.exitConfirmation = it }) { + descRes = R.string.exit_confirmation_desc + } } diff --git a/app/src/main/kotlin/com/pitchedapps/frost/settings/Debug.kt b/app/src/main/kotlin/com/pitchedapps/frost/settings/Debug.kt index 6ba2c64d7..63fc398d8 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/settings/Debug.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/settings/Debug.kt @@ -40,123 +40,114 @@ import com.pitchedapps.frost.prefs.Prefs import com.pitchedapps.frost.utils.L import com.pitchedapps.frost.utils.frostUriFromFile import com.pitchedapps.frost.utils.sendFrostEmail +import java.io.File import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.launch -import java.io.File /** * Created by Allan Wang on 2017-06-30. * - * A sub pref section that is enabled through a hidden preference - * Each category will load a page, extract the contents, remove private info, and create a report + * A sub pref section that is enabled through a hidden preference Each category will load a page, + * extract the contents, remove private info, and create a report */ fun SettingsActivity.getDebugPrefs(): KPrefAdapterBuilder.() -> Unit = { + plainText(R.string.disclaimer) { descRes = R.string.debug_disclaimer_info } - plainText(R.string.disclaimer) { - descRes = R.string.debug_disclaimer_info - } + plainText(R.string.debug_web) { + descRes = R.string.debug_web_desc + onClick = { this@getDebugPrefs.startActivityForResult(ACTIVITY_REQUEST_DEBUG) } + } - plainText(R.string.debug_web) { - descRes = R.string.debug_web_desc - onClick = - { this@getDebugPrefs.startActivityForResult(ACTIVITY_REQUEST_DEBUG) } - } + plainText(R.string.debug_parsers) { + descRes = R.string.debug_parsers_desc + onClick = { + val parsers = arrayOf(NotifParser, MessageParser, SearchParser) - plainText(R.string.debug_parsers) { - descRes = R.string.debug_parsers_desc - onClick = { + materialDialog { + // noinspection CheckResult + listItems(items = parsers.map { string(it.nameRes) }) { dialog, position, _ -> + dialog.dismiss() + val parser = parsers[position] + var attempt: Job? = null + val loading = materialDialog { + message(parser.nameRes) + // TODO change dialog? No more progress view + negativeButton(R.string.kau_cancel) { + attempt?.cancel() + it.dismiss() + } + cancelOnTouchOutside(false) + } - val parsers = arrayOf(NotifParser, MessageParser, SearchParser) - - materialDialog { - // noinspection CheckResult - listItems(items = parsers.map { string(it.nameRes) }) { dialog, position, _ -> - dialog.dismiss() - val parser = parsers[position] - var attempt: Job? = null - val loading = materialDialog { - message(parser.nameRes) - // TODO change dialog? No more progress view - negativeButton(R.string.kau_cancel) { - attempt?.cancel() - it.dismiss() - } - cancelOnTouchOutside(false) - } - - attempt = launch(Dispatchers.IO) { - try { - val data = parser.parse(fbCookie.webCookie) - withMainContext { - loading.dismiss() - createEmail(parser, data?.data, prefs) - } - } catch (e: Exception) { - createEmail(parser, "Error: ${e.message}", prefs) - } - } + attempt = + launch(Dispatchers.IO) { + try { + val data = parser.parse(fbCookie.webCookie) + withMainContext { + loading.dismiss() + createEmail(parser, data?.data, prefs) } + } catch (e: Exception) { + createEmail(parser, "Error: ${e.message}", prefs) + } } } + } } + } } private fun Context.createEmail(parser: FrostParser<*>, content: Any?, prefs: Prefs) = - sendFrostEmail( - "${string(R.string.debug_report)}: ${parser::class.java.simpleName}", - prefs = prefs - ) { - addItem("Url", parser.url) - addItem("Contents", "$content") - } + sendFrostEmail( + "${string(R.string.debug_report)}: ${parser::class.java.simpleName}", + prefs = prefs + ) { + addItem("Url", parser.url) + addItem("Contents", "$content") + } private const val ZIP_NAME = "debug" fun SettingsActivity.sendDebug(url: String, html: String?) { - val downloader = OfflineWebsite( - url, - cookie = fbCookie.webCookie ?: "", - baseUrl = FB_URL_BASE, - html = html, - baseDir = DebugActivity.baseDir(this) + val downloader = + OfflineWebsite( + url, + cookie = fbCookie.webCookie ?: "", + baseUrl = FB_URL_BASE, + html = html, + baseDir = DebugActivity.baseDir(this) ) - val job = Job() + val job = Job() - val md = materialDialog { - title(R.string.parsing_data) - // TODO remove dialog? No progress ui - negativeButton(R.string.kau_cancel) { it.dismiss() } - cancelOnTouchOutside(false) - onDismiss { job.cancel() } - } - - val progressFlow = MutableStateFlow(0) - -// progressFlow.onEach { md.setProgress(it) }.launchIn(this) - - launchMain { - val success = downloader.loadAndZip(ZIP_NAME) { - progressFlow.tryEmit(it) - } - md.dismiss() - if (success) { - val zipUri = frostUriFromFile( - File(downloader.baseDir, "$ZIP_NAME.zip") - ) - L.i { "Sending debug zip with uri $zipUri" } - sendFrostEmail(R.string.debug_report_email_title, prefs = prefs) { - addItem("Url", url) - addAttachment(zipUri) - extras = { - type = "application/zip" - } - } - } else { - toast(R.string.error_generic) - } + val md = materialDialog { + title(R.string.parsing_data) + // TODO remove dialog? No progress ui + negativeButton(R.string.kau_cancel) { it.dismiss() } + cancelOnTouchOutside(false) + onDismiss { job.cancel() } + } + + val progressFlow = MutableStateFlow(0) + + // progressFlow.onEach { md.setProgress(it) }.launchIn(this) + + launchMain { + val success = downloader.loadAndZip(ZIP_NAME) { progressFlow.tryEmit(it) } + md.dismiss() + if (success) { + val zipUri = frostUriFromFile(File(downloader.baseDir, "$ZIP_NAME.zip")) + L.i { "Sending debug zip with uri $zipUri" } + sendFrostEmail(R.string.debug_report_email_title, prefs = prefs) { + addItem("Url", url) + addAttachment(zipUri) + extras = { type = "application/zip" } + } + } else { + toast(R.string.error_generic) } + } } diff --git a/app/src/main/kotlin/com/pitchedapps/frost/settings/Experimental.kt b/app/src/main/kotlin/com/pitchedapps/frost/settings/Experimental.kt index 995500f01..acf0300e3 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/settings/Experimental.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/settings/Experimental.kt @@ -23,34 +23,30 @@ import com.pitchedapps.frost.R import com.pitchedapps.frost.activities.SettingsActivity import com.pitchedapps.frost.utils.REQUEST_RESTART_APPLICATION -/** - * Created by Allan Wang on 2017-06-29. - */ +/** Created by Allan Wang on 2017-06-29. */ fun SettingsActivity.getExperimentalPrefs(): KPrefAdapterBuilder.() -> Unit = { + plainText(R.string.disclaimer) { descRes = R.string.experimental_disclaimer_info } - plainText(R.string.disclaimer) { - descRes = R.string.experimental_disclaimer_info + // Experimental content starts here ------------------ + + // Experimental content ends here -------------------- + + checkbox( + R.string.verbose_logging, + prefs::verboseLogging, + { + prefs.verboseLogging = it + KL.shouldLog = { it != Log.VERBOSE } } + ) { + descRes = R.string.verbose_logging_desc + } - // Experimental content starts here ------------------ - - // Experimental content ends here -------------------- - - checkbox( - R.string.verbose_logging, prefs::verboseLogging, - { - prefs.verboseLogging = it - KL.shouldLog = { it != Log.VERBOSE } - } - ) { - descRes = R.string.verbose_logging_desc - } - - plainText(R.string.restart_frost) { - descRes = R.string.restart_frost_desc - onClick = { - setFrostResult(REQUEST_RESTART_APPLICATION) - finish() - } + plainText(R.string.restart_frost) { + descRes = R.string.restart_frost_desc + onClick = { + setFrostResult(REQUEST_RESTART_APPLICATION) + finish() } + } } diff --git a/app/src/main/kotlin/com/pitchedapps/frost/settings/Feed.kt b/app/src/main/kotlin/com/pitchedapps/frost/settings/Feed.kt index 914874a23..cf8dfcdce 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/settings/Feed.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/settings/Feed.kt @@ -25,117 +25,123 @@ import com.pitchedapps.frost.activities.SettingsActivity import com.pitchedapps.frost.enums.FeedSort import com.pitchedapps.frost.utils.REQUEST_FAB -/** - * Created by Allan Wang on 2017-06-29. - */ +/** Created by Allan Wang on 2017-06-29. */ fun SettingsActivity.getFeedPrefs(): KPrefAdapterBuilder.() -> Unit = { - - text(R.string.newsfeed_sort, prefs::feedSort, { prefs.feedSort = it }) { - descRes = R.string.newsfeed_sort_desc - onClick = { - materialDialog { - title(R.string.newsfeed_sort) - listItemsSingleChoice( - items = FeedSort.values().map { string(it.textRes) }, - initialSelection = item.pref - ) { _, index, _ -> - if (item.pref != index) { - item.pref = index - shouldRestartMain() - } - } - } + text(R.string.newsfeed_sort, prefs::feedSort, { prefs.feedSort = it }) { + descRes = R.string.newsfeed_sort_desc + onClick = { + materialDialog { + title(R.string.newsfeed_sort) + listItemsSingleChoice( + items = FeedSort.values().map { string(it.textRes) }, + initialSelection = item.pref + ) { _, index, _ -> + if (item.pref != index) { + item.pref = index + shouldRestartMain() + } } - textGetter = { string(FeedSort(it).textRes) } + } } + textGetter = { string(FeedSort(it).textRes) } + } - checkbox( - R.string.aggressive_recents, prefs::aggressiveRecents, - { - prefs.aggressiveRecents = it - shouldRefreshMain() - } - ) { - descRes = R.string.aggressive_recents_desc + checkbox( + R.string.aggressive_recents, + prefs::aggressiveRecents, + { + prefs.aggressiveRecents = it + shouldRefreshMain() } + ) { + descRes = R.string.aggressive_recents_desc + } - checkbox( - R.string.composer, prefs::showComposer, - { - prefs.showComposer = it - shouldRefreshMain() - } - ) { - descRes = R.string.composer_desc + checkbox( + R.string.composer, + prefs::showComposer, + { + prefs.showComposer = it + shouldRefreshMain() } + ) { + descRes = R.string.composer_desc + } - checkbox( - R.string.create_fab, prefs::showCreateFab, - { - prefs.showCreateFab = it - setFrostResult(REQUEST_FAB) - } - ) { - descRes = R.string.create_fab_desc + checkbox( + R.string.create_fab, + prefs::showCreateFab, + { + prefs.showCreateFab = it + setFrostResult(REQUEST_FAB) } + ) { + descRes = R.string.create_fab_desc + } - checkbox( - R.string.suggested_friends, prefs::showSuggestedFriends, - { - prefs.showSuggestedFriends = it - shouldRefreshMain() - } - ) { - descRes = R.string.suggested_friends_desc + checkbox( + R.string.suggested_friends, + prefs::showSuggestedFriends, + { + prefs.showSuggestedFriends = it + shouldRefreshMain() } + ) { + descRes = R.string.suggested_friends_desc + } - checkbox( - R.string.suggested_groups, prefs::showSuggestedGroups, - { - prefs.showSuggestedGroups = it - shouldRefreshMain() - } - ) { - descRes = R.string.suggested_groups_desc + checkbox( + R.string.suggested_groups, + prefs::showSuggestedGroups, + { + prefs.showSuggestedGroups = it + shouldRefreshMain() } + ) { + descRes = R.string.suggested_groups_desc + } - checkbox( - R.string.show_stories, prefs::showStories, - { - prefs.showStories = it - shouldRefreshMain() - } - ) { - descRes = R.string.show_stories_desc + checkbox( + R.string.show_stories, + prefs::showStories, + { + prefs.showStories = it + shouldRefreshMain() } + ) { + descRes = R.string.show_stories_desc + } - checkbox( - R.string.show_post_actions, prefs::showPostActions, - { - prefs.showPostActions = it - shouldRefreshMain() - } - ) { - descRes = R.string.show_post_actions_desc + checkbox( + R.string.show_post_actions, + prefs::showPostActions, + { + prefs.showPostActions = it + shouldRefreshMain() } + ) { + descRes = R.string.show_post_actions_desc + } - checkbox( - R.string.show_post_reactions, prefs::showPostReactions, - { - prefs.showPostReactions = it - shouldRefreshMain() - } - ) { - descRes = R.string.show_post_reactions_desc + checkbox( + R.string.show_post_reactions, + prefs::showPostReactions, + { + prefs.showPostReactions = it + shouldRefreshMain() } + ) { + descRes = R.string.show_post_reactions_desc + } - checkbox( - R.string.full_size_image, prefs::fullSizeImage, - { - prefs.fullSizeImage = it - shouldRefreshMain() - } - ) { - descRes = R.string.full_size_image_desc + checkbox( + R.string.full_size_image, + prefs::fullSizeImage, + { + prefs.fullSizeImage = it + shouldRefreshMain() } + ) { + descRes = R.string.full_size_image_desc + } } diff --git a/app/src/main/kotlin/com/pitchedapps/frost/settings/Network.kt b/app/src/main/kotlin/com/pitchedapps/frost/settings/Network.kt index 9df8bf86c..0b133afc9 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/settings/Network.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/settings/Network.kt @@ -20,16 +20,13 @@ import ca.allanwang.kau.kpref.activity.KPrefAdapterBuilder import com.pitchedapps.frost.R import com.pitchedapps.frost.activities.SettingsActivity -/** - * Created by Allan Wang on 2017-08-08. - */ +/** Created by Allan Wang on 2017-08-08. */ fun SettingsActivity.getNetworkPrefs(): KPrefAdapterBuilder.() -> Unit = { - - checkbox( - R.string.network_media_on_metered, - { !prefs.loadMediaOnMeteredNetwork }, - { prefs.loadMediaOnMeteredNetwork = !it } - ) { - descRes = R.string.network_media_on_metered_desc - } + checkbox( + R.string.network_media_on_metered, + { !prefs.loadMediaOnMeteredNetwork }, + { prefs.loadMediaOnMeteredNetwork = !it } + ) { + descRes = R.string.network_media_on_metered_desc + } } diff --git a/app/src/main/kotlin/com/pitchedapps/frost/settings/Notifications.kt b/app/src/main/kotlin/com/pitchedapps/frost/settings/Notifications.kt index a0fd2e3de..dd2359e7f 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/settings/Notifications.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/settings/Notifications.kt @@ -41,181 +41,166 @@ import com.pitchedapps.frost.utils.frostUri import com.pitchedapps.frost.views.Keywords import kotlinx.coroutines.launch -/** - * Created by Allan Wang on 2017-06-29. - */ - +/** Created by Allan Wang on 2017-06-29. */ val Prefs.hasNotifications: Boolean - get() = notificationsGeneral || notificationsInstantMessages + get() = notificationsGeneral || notificationsInstantMessages @SuppressLint("InlinedApi") fun SettingsActivity.getNotificationPrefs(): KPrefAdapterBuilder.() -> Unit = { + text(R.string.notification_frequency, prefs::notificationFreq, { prefs.notificationFreq = it }) { + val options = longArrayOf(15, 30, 60, 120, 180, 300, 1440, 2880) + val texts = options.map { if (it <= 0) string(R.string.no_notifications) else minuteToText(it) } + onClick = { + materialDialog { + title(R.string.notification_frequency) + listItemsSingleChoice(items = texts, initialSelection = options.indexOf(item.pref)) { + _, + index, + _ -> + item.pref = options[index] + setFrostResult(REQUEST_NOTIFICATION) + } + } + } + enabler = { prefs.hasNotifications } + textGetter = { minuteToText(it) } + } + + plainText(R.string.notification_keywords) { + descRes = R.string.notification_keywords_desc + onClick = { + val keywordView = Keywords(this@getNotificationPrefs) + materialDialog { + title(R.string.notification_keywords) + customView(view = keywordView) + positiveButton(R.string.kau_done) + onDismiss { keywordView.save() } + } + } + } + + checkbox( + R.string.notification_general, + prefs::notificationsGeneral, + { + prefs.notificationsGeneral = it + reloadByTitle(R.string.notification_general_all_accounts) + if (!prefs.notificationsInstantMessages) reloadByTitle(R.string.notification_frequency) + } + ) { + descRes = R.string.notification_general_desc + } + + checkbox( + R.string.notification_general_all_accounts, + prefs::notificationAllAccounts, + { prefs.notificationAllAccounts = it } + ) { + descRes = R.string.notification_general_all_accounts_desc + enabler = { prefs.notificationsGeneral } + } + + checkbox( + R.string.notification_messages, + prefs::notificationsInstantMessages, + { + prefs.notificationsInstantMessages = it + reloadByTitle(R.string.notification_messages_all_accounts) + if (!prefs.notificationsGeneral) reloadByTitle(R.string.notification_frequency) + } + ) { + descRes = R.string.notification_messages_desc + } + + checkbox( + R.string.notification_messages_all_accounts, + prefs::notificationsImAllAccounts, + { prefs.notificationsImAllAccounts = it } + ) { + descRes = R.string.notification_messages_all_accounts_desc + enabler = { prefs.notificationsInstantMessages } + } + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + plainText(R.string.notification_channel) { + descRes = R.string.notification_channel_desc + onClick = { + val intent = + Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS) + .putExtra(Settings.EXTRA_APP_PACKAGE, packageName) + startActivity(intent) + } + } + } else { + checkbox( + R.string.notification_sound, + prefs::notificationSound, + { + prefs.notificationSound = it + reloadByTitle(R.string.notification_ringtone, R.string.message_ringtone) + } + ) + + fun KPrefText.KPrefTextContract.ringtone(code: Int) { + enabler = prefs::notificationSound + textGetter = { + if (it.isBlank()) string(R.string.kau_default) + else + RingtoneManager.getRingtone(this@getNotificationPrefs, frostUri(it)) + ?.getTitle(this@getNotificationPrefs) + ?: "---" // todo figure out why this happens + } + onClick = { + val intent = + Intent(RingtoneManager.ACTION_RINGTONE_PICKER).apply { + putExtra(RingtoneManager.EXTRA_RINGTONE_TITLE, string(R.string.select_ringtone)) + putExtra(RingtoneManager.EXTRA_RINGTONE_SHOW_SILENT, false) + putExtra(RingtoneManager.EXTRA_RINGTONE_SHOW_DEFAULT, true) + putExtra(RingtoneManager.EXTRA_RINGTONE_TYPE, RingtoneManager.TYPE_NOTIFICATION) + if (item.pref.isNotBlank()) { + putExtra(RingtoneManager.EXTRA_RINGTONE_EXISTING_URI, frostUri(item.pref)) + } + } + startActivityForResult(intent, code) + } + } text( - R.string.notification_frequency, - prefs::notificationFreq, - { prefs.notificationFreq = it } + R.string.notification_ringtone, + prefs::notificationRingtone, + { prefs.notificationRingtone = it } ) { - val options = longArrayOf(15, 30, 60, 120, 180, 300, 1440, 2880) - val texts = - options.map { if (it <= 0) string(R.string.no_notifications) else minuteToText(it) } - onClick = { - materialDialog { - title(R.string.notification_frequency) - listItemsSingleChoice( - items = texts, - initialSelection = options.indexOf(item.pref) - ) { _, index, _ -> - item.pref = options[index] - setFrostResult(REQUEST_NOTIFICATION) - } - } - } - enabler = { prefs.hasNotifications } - textGetter = { minuteToText(it) } + ringtone(SettingsActivity.REQUEST_NOTIFICATION_RINGTONE) } - plainText(R.string.notification_keywords) { - descRes = R.string.notification_keywords_desc - onClick = { - val keywordView = Keywords(this@getNotificationPrefs) - materialDialog { - title(R.string.notification_keywords) - customView(view = keywordView) - positiveButton(R.string.kau_done) - onDismiss { keywordView.save() } - } - } + text(R.string.message_ringtone, prefs::messageRingtone, { prefs.messageRingtone = it }) { + ringtone(SettingsActivity.REQUEST_MESSAGE_RINGTONE) } checkbox( - R.string.notification_general, prefs::notificationsGeneral, - { - prefs.notificationsGeneral = it - reloadByTitle(R.string.notification_general_all_accounts) - if (!prefs.notificationsInstantMessages) - reloadByTitle(R.string.notification_frequency) - } - ) { - descRes = R.string.notification_general_desc - } + R.string.notification_vibrate, + prefs::notificationVibrate, + { prefs.notificationVibrate = it } + ) checkbox( - R.string.notification_general_all_accounts, prefs::notificationAllAccounts, - { prefs.notificationAllAccounts = it } - ) { - descRes = R.string.notification_general_all_accounts_desc - enabler = { prefs.notificationsGeneral } - } - - checkbox( - R.string.notification_messages, prefs::notificationsInstantMessages, - { - prefs.notificationsInstantMessages = it - reloadByTitle(R.string.notification_messages_all_accounts) - if (!prefs.notificationsGeneral) - reloadByTitle(R.string.notification_frequency) - } - ) { - descRes = R.string.notification_messages_desc - } - - checkbox( - R.string.notification_messages_all_accounts, prefs::notificationsImAllAccounts, - { prefs.notificationsImAllAccounts = it } - ) { - descRes = R.string.notification_messages_all_accounts_desc - enabler = { prefs.notificationsInstantMessages } - } - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - plainText(R.string.notification_channel) { - descRes = R.string.notification_channel_desc - onClick = { - val intent = Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS) - .putExtra(Settings.EXTRA_APP_PACKAGE, packageName) - startActivity(intent) - } - } - } else { - checkbox( - R.string.notification_sound, prefs::notificationSound, - { - prefs.notificationSound = it - reloadByTitle( - R.string.notification_ringtone, - R.string.message_ringtone - ) - } - ) - - fun KPrefText.KPrefTextContract.ringtone(code: Int) { - enabler = prefs::notificationSound - textGetter = { - if (it.isBlank()) string(R.string.kau_default) - else RingtoneManager.getRingtone(this@getNotificationPrefs, frostUri(it)) - ?.getTitle(this@getNotificationPrefs) - ?: "---" // todo figure out why this happens - } - onClick = { - val intent = Intent(RingtoneManager.ACTION_RINGTONE_PICKER).apply { - putExtra(RingtoneManager.EXTRA_RINGTONE_TITLE, string(R.string.select_ringtone)) - putExtra(RingtoneManager.EXTRA_RINGTONE_SHOW_SILENT, false) - putExtra(RingtoneManager.EXTRA_RINGTONE_SHOW_DEFAULT, true) - putExtra(RingtoneManager.EXTRA_RINGTONE_TYPE, RingtoneManager.TYPE_NOTIFICATION) - if (item.pref.isNotBlank()) { - putExtra( - RingtoneManager.EXTRA_RINGTONE_EXISTING_URI, - frostUri(item.pref) - ) - } - } - startActivityForResult(intent, code) - } - } - - text( - R.string.notification_ringtone, prefs::notificationRingtone, - { prefs.notificationRingtone = it } - ) { - ringtone(SettingsActivity.REQUEST_NOTIFICATION_RINGTONE) - } - - text( - R.string.message_ringtone, prefs::messageRingtone, - { prefs.messageRingtone = it } - ) { - ringtone(SettingsActivity.REQUEST_MESSAGE_RINGTONE) - } - - checkbox( - R.string.notification_vibrate, prefs::notificationVibrate, - { prefs.notificationVibrate = it } - ) - - checkbox( - R.string.notification_lights, prefs::notificationLights, - { prefs.notificationLights = it } - ) - } - - if (BuildConfig.DEBUG) { - plainText(R.string.reset_notif_epoch) { - onClick = { - launch { - notifDao.deleteAll() - } - } - } - } - - plainText(R.string.notification_fetch_now) { - descRes = R.string.notification_fetch_now_desc - onClick = { - val text = - if (fetchNotifications()) R.string.notification_fetch_success - else R.string.notification_fetch_fail - frostSnackbar(text, themeProvider) - } + R.string.notification_lights, + prefs::notificationLights, + { prefs.notificationLights = it } + ) + } + + if (BuildConfig.DEBUG) { + plainText(R.string.reset_notif_epoch) { onClick = { launch { notifDao.deleteAll() } } } + } + + plainText(R.string.notification_fetch_now) { + descRes = R.string.notification_fetch_now_desc + onClick = { + val text = + if (fetchNotifications()) R.string.notification_fetch_success + else R.string.notification_fetch_fail + frostSnackbar(text, themeProvider) } + } } diff --git a/app/src/main/kotlin/com/pitchedapps/frost/settings/Security.kt b/app/src/main/kotlin/com/pitchedapps/frost/settings/Security.kt index 61c69f10f..fff85555f 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/settings/Security.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/settings/Security.kt @@ -22,31 +22,27 @@ import com.pitchedapps.frost.activities.SettingsActivity import com.pitchedapps.frost.utils.BiometricUtils import kotlinx.coroutines.launch -/** - * Created by Allan Wang on 20179-05-01. - */ +/** Created by Allan Wang on 20179-05-01. */ fun SettingsActivity.getSecurityPrefs(): KPrefAdapterBuilder.() -> Unit = { + plainText(R.string.disclaimer) { descRes = R.string.security_disclaimer_info } - plainText(R.string.disclaimer) { - descRes = R.string.security_disclaimer_info - } - - checkbox( - R.string.enable_biometrics, prefs::biometricsEnabled, - { - launch { - /* - * For security, we should request authentication when: - * - enabling to ensure that it is supported - * - disabling to ensure that it is permitted - */ - BiometricUtils.authenticate(this@getSecurityPrefs, prefs, force = true).await() - prefs.biometricsEnabled = it - reloadByTitle(R.string.enable_biometrics) - } - } - ) { - descRes = R.string.enable_biometrics_desc - enabler = { BiometricUtils.isSupported(this@getSecurityPrefs) } + checkbox( + R.string.enable_biometrics, + prefs::biometricsEnabled, + { + launch { + /* + * For security, we should request authentication when: + * - enabling to ensure that it is supported + * - disabling to ensure that it is permitted + */ + BiometricUtils.authenticate(this@getSecurityPrefs, prefs, force = true).await() + prefs.biometricsEnabled = it + reloadByTitle(R.string.enable_biometrics) + } } + ) { + descRes = R.string.enable_biometrics_desc + enabler = { BiometricUtils.isSupported(this@getSecurityPrefs) } + } } diff --git a/app/src/main/kotlin/com/pitchedapps/frost/utils/AdBlocker.kt b/app/src/main/kotlin/com/pitchedapps/frost/utils/AdBlocker.kt index 77c71aa94..e88603f03 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/utils/AdBlocker.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/utils/AdBlocker.kt @@ -23,43 +23,38 @@ import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.launch import okhttp3.HttpUrl.Companion.toHttpUrlOrNull -/** - * Created by Allan Wang on 2017-09-24. - */ +/** Created by Allan Wang on 2017-09-24. */ object FrostAdBlock : AdBlocker("adblock.txt") object FrostPglAdBlock : AdBlocker("pgl.yoyo.org.txt") -/** - * Base implementation of an AdBlocker - * Wrap this in a singleton and initialize it to use it - */ +/** Base implementation of an AdBlocker Wrap this in a singleton and initialize it to use it */ open class AdBlocker(val assetPath: String) { - val data: MutableSet = mutableSetOf() + val data: MutableSet = mutableSetOf() - fun init(context: Context) { - GlobalScope.launch { - val content = context.assets.open(assetPath).bufferedReader().use { f -> - f.readLines().filter { !it.startsWith("#") } - } - data.addAll(content) - L.i { "Initialized adblock for $assetPath with ${data.size} hosts" } + fun init(context: Context) { + GlobalScope.launch { + val content = + context.assets.open(assetPath).bufferedReader().use { f -> + f.readLines().filter { !it.startsWith("#") } } + data.addAll(content) + L.i { "Initialized adblock for $assetPath with ${data.size} hosts" } } + } - fun isAd(url: String?): Boolean { - url ?: return false - val httpUrl = url.toHttpUrlOrNull() ?: return false - return isAdHost(httpUrl.host) - } + fun isAd(url: String?): Boolean { + url ?: return false + val httpUrl = url.toHttpUrlOrNull() ?: return false + return isAdHost(httpUrl.host) + } - tailrec fun isAdHost(host: String): Boolean { - if (TextUtils.isEmpty(host)) - return false - val index = host.indexOf(".") - if (index < 0 || index + 1 < host.length) return false - if (data.contains(host)) return true - return isAdHost(host.substring(index + 1)) - } + tailrec fun isAdHost(host: String): Boolean { + if (TextUtils.isEmpty(host)) return false + val index = host.indexOf(".") + if (index < 0 || index + 1 < host.length) return false + if (data.contains(host)) return true + return isAdHost(host.substring(index + 1)) + } } diff --git a/app/src/main/kotlin/com/pitchedapps/frost/utils/AnimatedVectorDelegate.kt b/app/src/main/kotlin/com/pitchedapps/frost/utils/AnimatedVectorDelegate.kt index 0195f168d..df4c80f07 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/utils/AnimatedVectorDelegate.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/utils/AnimatedVectorDelegate.kt @@ -24,73 +24,73 @@ import ca.allanwang.kau.utils.drawable /** * Created by Allan Wang on 2017-07-29. * - * Delegate for animated vector drawables with two states (start and end) - * Drawables are added lazily depending on the animation direction, and are verified upon load - * Should the bounded view not have an animated drawable upon animating, it is assumed - * that the user has switched the resource themselves and the delegate will not switch the resource + * Delegate for animated vector drawables with two states (start and end) Drawables are added lazily + * depending on the animation direction, and are verified upon load Should the bounded view not have + * an animated drawable upon animating, it is assumed that the user has switched the resource + * themselves and the delegate will not switch the resource */ interface AnimatedVectorContract { - fun animate() - fun animateReverse() - fun animateToggle() - val isAtStart: Boolean - fun bind(view: ImageView) - var animatedVectorListener: ((avd: AnimatedVectorDrawable, forwards: Boolean) -> Unit)? + fun animate() + fun animateReverse() + fun animateToggle() + val isAtStart: Boolean + fun bind(view: ImageView) + var animatedVectorListener: ((avd: AnimatedVectorDrawable, forwards: Boolean) -> Unit)? } class AnimatedVectorDelegate( - /** - * The res for the starting resource; must have parent tag animated-vector - */ - @param:DrawableRes val avdStart: Int, - /** - * The res for the ending resource; must have parent tag animated-vector - */ - @param:DrawableRes val avdEnd: Int, - /** - * The delegate will automatically set the start resource when bound - * If [emitOnBind] is true, it will also trigger the listener - */ - val emitOnBind: Boolean = true, - /** - * The optional listener that will be triggered every time the avd is switched by the delegate - */ - override var animatedVectorListener: ((avd: AnimatedVectorDrawable, forwards: Boolean) -> Unit)? = null + /** The res for the starting resource; must have parent tag animated-vector */ + @param:DrawableRes val avdStart: Int, + /** The res for the ending resource; must have parent tag animated-vector */ + @param:DrawableRes val avdEnd: Int, + /** + * The delegate will automatically set the start resource when bound If [emitOnBind] is true, it + * will also trigger the listener + */ + val emitOnBind: Boolean = true, + /** The optional listener that will be triggered every time the avd is switched by the delegate */ + override var animatedVectorListener: ((avd: AnimatedVectorDrawable, forwards: Boolean) -> Unit)? = + null ) : AnimatedVectorContract { - lateinit var view: ImageView + lateinit var view: ImageView - private var atStart = true + private var atStart = true - override val isAtStart: Boolean - get() = atStart + override val isAtStart: Boolean + get() = atStart - private val avd: AnimatedVectorDrawable? - get() = view.drawable as? AnimatedVectorDrawable + private val avd: AnimatedVectorDrawable? + get() = view.drawable as? AnimatedVectorDrawable - override fun bind(view: ImageView) { - this.view = view - view.context.drawable(avdStart) as? AnimatedVectorDrawable - ?: throw IllegalArgumentException("AnimatedVectorDelegate has a starting drawable that isn't an avd") - view.context.drawable(avdEnd) as? AnimatedVectorDrawable - ?: throw IllegalArgumentException("AnimatedVectorDelegate has an ending drawable that isn't an avd") - view.setImageResource(avdStart) - if (emitOnBind) animatedVectorListener?.invoke(avd!!, false) - } + override fun bind(view: ImageView) { + this.view = view + view.context.drawable(avdStart) as? AnimatedVectorDrawable + ?: throw IllegalArgumentException( + "AnimatedVectorDelegate has a starting drawable that isn't an avd" + ) + view.context.drawable(avdEnd) as? AnimatedVectorDrawable + ?: throw IllegalArgumentException( + "AnimatedVectorDelegate has an ending drawable that isn't an avd" + ) + view.setImageResource(avdStart) + if (emitOnBind) animatedVectorListener?.invoke(avd!!, false) + } - override fun animate() = animateImpl(false) + override fun animate() = animateImpl(false) - override fun animateReverse() = animateImpl(true) + override fun animateReverse() = animateImpl(true) - override fun animateToggle() = animateImpl(!atStart) + override fun animateToggle() = animateImpl(!atStart) - private fun animateImpl(toStart: Boolean) { - if ((atStart == toStart)) return L.d { "AVD already at ${if (toStart) "start" else "end"}" } - if (avd == null) return L.d { "AVD null resource" } // no longer using animated vector; do not modify - avd?.stop() - view.setImageResource(if (toStart) avdEnd else avdStart) - animatedVectorListener?.invoke(avd!!, !toStart) - atStart = toStart - avd?.start() - } + private fun animateImpl(toStart: Boolean) { + if ((atStart == toStart)) return L.d { "AVD already at ${if (toStart) "start" else "end"}" } + if (avd == null) + return L.d { "AVD null resource" } // no longer using animated vector; do not modify + avd?.stop() + view.setImageResource(if (toStart) avdEnd else avdStart) + animatedVectorListener?.invoke(avd!!, !toStart) + atStart = toStart + avd?.start() + } } diff --git a/app/src/main/kotlin/com/pitchedapps/frost/utils/BiometricUtils.kt b/app/src/main/kotlin/com/pitchedapps/frost/utils/BiometricUtils.kt index 1860d6f6a..3a7481bc5 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/utils/BiometricUtils.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/utils/BiometricUtils.kt @@ -27,110 +27,109 @@ import androidx.lifecycle.OnLifecycleEvent import ca.allanwang.kau.utils.string import com.pitchedapps.frost.R import com.pitchedapps.frost.prefs.Prefs -import kotlinx.coroutines.CompletableDeferred import java.util.concurrent.Executor import java.util.concurrent.ExecutorService import java.util.concurrent.Executors +import kotlinx.coroutines.CompletableDeferred typealias BiometricDeferred = CompletableDeferred -/** - * Container for [BiometricPrompt] - * Inspired by coroutine's CommonPool - */ +/** Container for [BiometricPrompt] Inspired by coroutine's CommonPool */ object BiometricUtils { - private val executor: Executor - get() = pool ?: getOrCreatePoolSync() + private val executor: Executor + get() = pool ?: getOrCreatePoolSync() - @Volatile - private var pool: ExecutorService? = null + @Volatile private var pool: ExecutorService? = null - private var lastUnlockTime = -1L + private var lastUnlockTime = -1L - private const val UNLOCK_TIME_INTERVAL = 15 * 60 * 1000 + private const val UNLOCK_TIME_INTERVAL = 15 * 60 * 1000 - /** - * Checks if biometric authentication is possible - * Currently, this means checking for enrolled fingerprints - */ - @Suppress("DEPRECATION") - fun isSupported(context: Context): Boolean { - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) return false - val fingerprintManager = - context.getSystemService(FingerprintManager::class.java) ?: return false - return fingerprintManager.isHardwareDetected && fingerprintManager.hasEnrolledFingerprints() + /** + * Checks if biometric authentication is possible Currently, this means checking for enrolled + * fingerprints + */ + @Suppress("DEPRECATION") + fun isSupported(context: Context): Boolean { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) return false + val fingerprintManager = + context.getSystemService(FingerprintManager::class.java) ?: return false + return fingerprintManager.isHardwareDetected && fingerprintManager.hasEnrolledFingerprints() + } + + private fun getOrCreatePoolSync(): Executor = + pool ?: Executors.newSingleThreadExecutor().also { pool = it } + + private fun shouldPrompt(context: Context, prefs: Prefs): Boolean { + return prefs.biometricsEnabled && + System.currentTimeMillis() - lastUnlockTime > UNLOCK_TIME_INTERVAL + } + + /** + * Generates a prompt dialog and attempt to return an auth object. Note that the underlying + * request will call [androidx.fragment.app.FragmentTransaction.commit], so this cannot happen + * after onSaveInstanceState. + */ + fun authenticate( + activity: FragmentActivity, + prefs: Prefs, + force: Boolean = false + ): BiometricDeferred { + val deferred: BiometricDeferred = CompletableDeferred() + if (!force && !shouldPrompt(activity, prefs)) { + deferred.complete(null) + return deferred } - - private fun getOrCreatePoolSync(): Executor = - pool ?: Executors.newSingleThreadExecutor().also { pool = it } - - private fun shouldPrompt(context: Context, prefs: Prefs): Boolean { - return prefs.biometricsEnabled && System.currentTimeMillis() - lastUnlockTime > UNLOCK_TIME_INTERVAL - } - - /** - * Generates a prompt dialog and attempt to return an auth object. - * Note that the underlying request will call [androidx.fragment.app.FragmentTransaction.commit], - * so this cannot happen after onSaveInstanceState. - */ - fun authenticate( - activity: FragmentActivity, - prefs: Prefs, - force: Boolean = false - ): BiometricDeferred { - val deferred: BiometricDeferred = CompletableDeferred() - if (!force && !shouldPrompt(activity, prefs)) { - deferred.complete(null) - return deferred - } - val info = BiometricPrompt.PromptInfo.Builder() - .setTitle(activity.string(R.string.biometrics_prompt_title)) - .setNegativeButtonText(activity.string(R.string.kau_cancel)) - .build() - val prompt = BiometricPrompt(activity, executor, Callback(activity, deferred)) - activity.lifecycle.addObserver(object : LifecycleObserver { - @OnLifecycleEvent(Lifecycle.Event.ON_PAUSE) - fun onPause() { - if (!deferred.isCompleted) { - prompt.cancelAuthentication() - deferred.cancel() - activity.finish() - } - activity.lifecycle.removeObserver(this) - } - }) - prompt.authenticate(info) - return deferred - } - - private class Callback(val activity: FragmentActivity, val deferred: BiometricDeferred) : - BiometricPrompt.AuthenticationCallback() { - override fun onAuthenticationError(errorCode: Int, errString: CharSequence) { + val info = + BiometricPrompt.PromptInfo.Builder() + .setTitle(activity.string(R.string.biometrics_prompt_title)) + .setNegativeButtonText(activity.string(R.string.kau_cancel)) + .build() + val prompt = BiometricPrompt(activity, executor, Callback(activity, deferred)) + activity.lifecycle.addObserver( + object : LifecycleObserver { + @OnLifecycleEvent(Lifecycle.Event.ON_PAUSE) + fun onPause() { + if (!deferred.isCompleted) { + prompt.cancelAuthentication() deferred.cancel() activity.finish() + } + activity.lifecycle.removeObserver(this) } + } + ) + prompt.authenticate(info) + return deferred + } - override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) { - lastUnlockTime = System.currentTimeMillis() - deferred.complete(result.cryptoObject) - } - - override fun onAuthenticationFailed() { - deferred.cancel() - activity.finish() - } + private class Callback(val activity: FragmentActivity, val deferred: BiometricDeferred) : + BiometricPrompt.AuthenticationCallback() { + override fun onAuthenticationError(errorCode: Int, errString: CharSequence) { + deferred.cancel() + activity.finish() } - /** - * For completeness we provide a shutdown function. - * In practice, we initialize the executor only when it is first used, - * and keep it alive throughout the app lifecycle, as it will be used an arbitrary number of times, - * with unknown frequency - */ - @Synchronized - fun shutdown() { - pool?.shutdown() - pool = null + override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) { + lastUnlockTime = System.currentTimeMillis() + deferred.complete(result.cryptoObject) } + + override fun onAuthenticationFailed() { + deferred.cancel() + activity.finish() + } + } + + /** + * For completeness we provide a shutdown function. In practice, we initialize the executor only + * when it is first used, and keep it alive throughout the app lifecycle, as it will be used an + * arbitrary number of times, with unknown frequency + */ + @Synchronized + fun shutdown() { + pool?.shutdown() + pool = null + } } diff --git a/app/src/main/kotlin/com/pitchedapps/frost/utils/BuildUtils.kt b/app/src/main/kotlin/com/pitchedapps/frost/utils/BuildUtils.kt index d922ff54d..b537076e1 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/utils/BuildUtils.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/utils/BuildUtils.kt @@ -18,23 +18,23 @@ package com.pitchedapps.frost.utils object BuildUtils { - data class Data(val versionName: String, val tail: String) + data class Data(val versionName: String, val tail: String) - // Builds - private const val BUILD_PRODUCTION = "production" - private const val BUILD_TEST = "releaseTest" - private const val BUILD_GITHUB = "github" - private const val BUILD_RELEASE = "release" - private const val BUILD_UNNAMED = "unnamed" + // Builds + private const val BUILD_PRODUCTION = "production" + private const val BUILD_TEST = "releaseTest" + private const val BUILD_GITHUB = "github" + private const val BUILD_RELEASE = "release" + private const val BUILD_UNNAMED = "unnamed" - fun match(version: String): Data? { - val regex = Regex("([0-9]+\\.[0-9]+\\.[0-9]+)-?(.*?)") - val result = regex.matchEntire(version)?.groupValues ?: return null - return Data("v${result[1]}", result[2]) - } + fun match(version: String): Data? { + val regex = Regex("([0-9]+\\.[0-9]+\\.[0-9]+)-?(.*?)") + val result = regex.matchEntire(version)?.groupValues ?: return null + return Data("v${result[1]}", result[2]) + } - fun getAllStages(): Set = - setOf(BUILD_PRODUCTION, BUILD_TEST, BUILD_GITHUB, BUILD_RELEASE, BUILD_UNNAMED) + fun getAllStages(): Set = + setOf(BUILD_PRODUCTION, BUILD_TEST, BUILD_GITHUB, BUILD_RELEASE, BUILD_UNNAMED) - fun getStage(build: String): String = build.takeIf { it in getAllStages() } ?: BUILD_UNNAMED + fun getStage(build: String): String = build.takeIf { it in getAllStages() } ?: BUILD_UNNAMED } diff --git a/app/src/main/kotlin/com/pitchedapps/frost/utils/Const.kt b/app/src/main/kotlin/com/pitchedapps/frost/utils/Const.kt index e0ad802e2..8390ae1bb 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/utils/Const.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/utils/Const.kt @@ -16,9 +16,7 @@ */ package com.pitchedapps.frost.utils -/** - * Created by Allan Wang on 20/12/17. - */ +/** Created by Allan Wang on 20/12/17. */ const val ACTIVITY_SETTINGS = 97 /* diff --git a/app/src/main/kotlin/com/pitchedapps/frost/utils/Downloader.kt b/app/src/main/kotlin/com/pitchedapps/frost/utils/Downloader.kt index 49ec5f94b..261651849 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/utils/Downloader.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/utils/Downloader.kt @@ -35,67 +35,67 @@ import com.pitchedapps.frost.facebook.USER_AGENT /** * Created by Allan Wang on 2017-08-04. * - * With reference to Stack Overflow + * With reference to Stack + * Overflow */ fun Context.frostDownload( - cookie: String?, - url: String?, - userAgent: String = USER_AGENT, - contentDisposition: String? = null, - mimeType: String? = null, - contentLength: Long = 0L + cookie: String?, + url: String?, + userAgent: String = USER_AGENT, + contentDisposition: String? = null, + mimeType: String? = null, + contentLength: Long = 0L ) { - url ?: return - frostDownload(cookie, Uri.parse(url), userAgent, contentDisposition, mimeType, contentLength) + url ?: return + frostDownload(cookie, Uri.parse(url), userAgent, contentDisposition, mimeType, contentLength) } fun Context.frostDownload( - cookie: String?, - uri: Uri?, - userAgent: String = USER_AGENT, - contentDisposition: String? = null, - mimeType: String? = null, - contentLength: Long = 0L + cookie: String?, + uri: Uri?, + userAgent: String = USER_AGENT, + contentDisposition: String? = null, + mimeType: String? = null, + contentLength: Long = 0L ) { - uri ?: return - L.d { "Received download request" } - if (uri.scheme != "http" && uri.scheme != "https") { - toast(R.string.error_invalid_download) - return L.e { "Invalid download $uri" } + uri ?: return + L.d { "Received download request" } + if (uri.scheme != "http" && uri.scheme != "https") { + toast(R.string.error_invalid_download) + return L.e { "Invalid download $uri" } + } + val dm = getSystemService() + if (dm == null || !isAppEnabled(DOWNLOAD_MANAGER_PACKAGE)) { + materialDialog { + title(R.string.no_download_manager) + message(R.string.no_download_manager_desc) + positiveButton(R.string.kau_yes) { showAppInfo(DOWNLOAD_MANAGER_PACKAGE) } + negativeButton(R.string.kau_no) } - val dm = getSystemService() - if (dm == null || !isAppEnabled(DOWNLOAD_MANAGER_PACKAGE)) { - materialDialog { - title(R.string.no_download_manager) - message(R.string.no_download_manager_desc) - positiveButton(R.string.kau_yes) { - showAppInfo(DOWNLOAD_MANAGER_PACKAGE) - } - negativeButton(R.string.kau_no) - } - return + return + } + kauRequestPermissions(PERMISSION_WRITE_EXTERNAL_STORAGE) { granted, _ -> + if (!granted) return@kauRequestPermissions + val request = DownloadManager.Request(uri) + request.setMimeType(mimeType) + val title = URLUtil.guessFileName(uri.toString(), contentDisposition, mimeType) + if (cookie != null) { + request.addRequestHeader("Cookie", cookie) } - kauRequestPermissions(PERMISSION_WRITE_EXTERNAL_STORAGE) { granted, _ -> - if (!granted) return@kauRequestPermissions - val request = DownloadManager.Request(uri) - request.setMimeType(mimeType) - val title = URLUtil.guessFileName(uri.toString(), contentDisposition, mimeType) - if (cookie != null) { - request.addRequestHeader("Cookie", cookie) - } - request.addRequestHeader("User-Agent", userAgent) - request.setDescription(string(R.string.downloading)) - request.setTitle(title) - request.allowScanningByMediaScanner() - request.setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED) - request.setDestinationInExternalPublicDir(Environment.DIRECTORY_DOWNLOADS, "Frost/$title") - try { - dm.enqueue(request) - } catch (e: Exception) { - toast(R.string.error_generic) - L.e(e) { "Download" } - } + request.addRequestHeader("User-Agent", userAgent) + request.setDescription(string(R.string.downloading)) + request.setTitle(title) + request.allowScanningByMediaScanner() + request.setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED) + request.setDestinationInExternalPublicDir(Environment.DIRECTORY_DOWNLOADS, "Frost/$title") + try { + dm.enqueue(request) + } catch (e: Exception) { + toast(R.string.error_generic) + L.e(e) { "Download" } } + } } private const val DOWNLOAD_MANAGER_PACKAGE = "com.android.providers.downloads" diff --git a/app/src/main/kotlin/com/pitchedapps/frost/utils/EnumUtils.kt b/app/src/main/kotlin/com/pitchedapps/frost/utils/EnumUtils.kt index b6d9b8338..153b08755 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/utils/EnumUtils.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/utils/EnumUtils.kt @@ -22,51 +22,51 @@ import android.os.BaseBundle /** * Created by Allan Wang on 29/12/17. * - * Helper to set enum using its name rather than the serialized version - * Name is used in case the enum is involved in persistent data, where updates may shift indices + * Helper to set enum using its name rather than the serialized version Name is used in case the + * enum is involved in persistent data, where updates may shift indices */ interface EnumBundle> { - val bundleContract: EnumBundleCompanion + val bundleContract: EnumBundleCompanion - val name: String + val name: String - val ordinal: Int + val ordinal: Int - fun put(intent: Intent) { - intent.putExtra(bundleContract.argTag, name) - } + fun put(intent: Intent) { + intent.putExtra(bundleContract.argTag, name) + } - fun put(bundle: BaseBundle?) { - bundle?.putString(bundleContract.argTag, name) - } + fun put(bundle: BaseBundle?) { + bundle?.putString(bundleContract.argTag, name) + } } interface EnumBundleCompanion> { - val argTag: String + val argTag: String - val values: Array + val values: Array - val valueMap: Map + val valueMap: Map - operator fun get(name: String?) = if (name == null) null else valueMap[name] + operator fun get(name: String?) = if (name == null) null else valueMap[name] - operator fun get(bundle: BaseBundle?) = get(bundle?.getString(argTag)) + operator fun get(bundle: BaseBundle?) = get(bundle?.getString(argTag)) - operator fun get(intent: Intent?) = get(intent?.getStringExtra(argTag)) + operator fun get(intent: Intent?) = get(intent?.getStringExtra(argTag)) } open class EnumCompanion>( - final override val argTag: String, - final override val values: Array + final override val argTag: String, + final override val values: Array ) : EnumBundleCompanion { - final override val valueMap: Map = values.map { it.name to it }.toMap() + final override val valueMap: Map = values.map { it.name to it }.toMap() - final override fun get(name: String?) = super.get(name) + final override fun get(name: String?) = super.get(name) - final override fun get(bundle: BaseBundle?) = super.get(bundle) + final override fun get(bundle: BaseBundle?) = super.get(bundle) - final override fun get(intent: Intent?) = super.get(intent) + final override fun get(intent: Intent?) = super.get(intent) } diff --git a/app/src/main/kotlin/com/pitchedapps/frost/utils/JsoupCleaner.kt b/app/src/main/kotlin/com/pitchedapps/frost/utils/JsoupCleaner.kt index 4f76fb6c9..50e3728e4 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/utils/JsoupCleaner.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/utils/JsoupCleaner.kt @@ -36,17 +36,30 @@ internal fun String.cleanJsoup(): String = Jsoup.clean(this, PrivacyWhitelist()) class PrivacyWhitelist : Safelist() { - val blacklistAttrs = arrayOf("style", "aria-label", "rel") - val blacklistTags = arrayOf( - "body", "html", "head", "i", "b", "u", "style", "script", - "br", "p", "span", "ul", "ol", "li" + val blacklistAttrs = arrayOf("style", "aria-label", "rel") + val blacklistTags = + arrayOf( + "body", + "html", + "head", + "i", + "b", + "u", + "style", + "script", + "br", + "p", + "span", + "ul", + "ol", + "li" ) - override fun isSafeAttribute(tagName: String, el: Element, attr: Attribute): Boolean { - val key = attr.key - if (key == "href") attr.setValue("-") - return key !in blacklistAttrs - } + override fun isSafeAttribute(tagName: String, el: Element, attr: Attribute): Boolean { + val key = attr.key + if (key == "href") attr.setValue("-") + return key !in blacklistAttrs + } - override fun isSafeTag(tag: String) = tag !in blacklistTags + override fun isSafeTag(tag: String) = tag !in blacklistTags } diff --git a/app/src/main/kotlin/com/pitchedapps/frost/utils/L.kt b/app/src/main/kotlin/com/pitchedapps/frost/utils/L.kt index 0f1a9f49f..e689da40b 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/utils/L.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/utils/L.kt @@ -27,33 +27,31 @@ import com.pitchedapps.frost.BuildConfig */ object L : KauLogger("Frost") { - inline fun test(message: () -> Any?) { - _d { - "Test1234 ${message()}" - } - } + inline fun test(message: () -> Any?) { + _d { "Test1234 ${message()}" } + } - inline fun _i(message: () -> Any?) { - if (BuildConfig.DEBUG) { - i(message) - } + inline fun _i(message: () -> Any?) { + if (BuildConfig.DEBUG) { + i(message) } + } - inline fun _d(message: () -> Any?) { - if (BuildConfig.DEBUG) { - d(message) - } + inline fun _d(message: () -> Any?) { + if (BuildConfig.DEBUG) { + d(message) } + } - inline fun _e(e: Throwable?, message: () -> Any?) { - if (BuildConfig.DEBUG) { - e(e, message) - } + inline fun _e(e: Throwable?, message: () -> Any?) { + if (BuildConfig.DEBUG) { + e(e, message) } + } } fun KauLoggerExtension.test(message: () -> Any?) { - if (BuildConfig.DEBUG) { - d { "Test1234 ${message()}" } - } + if (BuildConfig.DEBUG) { + d { "Test1234 ${message()}" } + } } diff --git a/app/src/main/kotlin/com/pitchedapps/frost/utils/TimeUtils.kt b/app/src/main/kotlin/com/pitchedapps/frost/utils/TimeUtils.kt index 6156badb4..515e4ab08 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/utils/TimeUtils.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/utils/TimeUtils.kt @@ -26,35 +26,26 @@ import java.util.Date import java.util.Locale /** - * Converts time in millis to readable date, - * eg Apr 24 at 7:32 PM + * Converts time in millis to readable date, eg Apr 24 at 7:32 PM * - * With regards to date modifications in calendars, - * it appears to respect calendar rules; - * see https://stackoverflow.com/a/43227817/4407321 + * With regards to date modifications in calendars, it appears to respect calendar rules; see + * https://stackoverflow.com/a/43227817/4407321 */ fun Long.toReadableTime(context: Context): String { - val cal = Calendar.getInstance() - cal.timeInMillis = this - val timeFormatter = SimpleDateFormat.getTimeInstance(DateFormat.SHORT) - val time = timeFormatter.format(Date(this)) - val day = when { - cal >= Calendar.getInstance().apply { - add( - Calendar.DAY_OF_MONTH, - -1 - ) - } -> context.string(R.string.today) - cal >= Calendar.getInstance().apply { - add( - Calendar.DAY_OF_MONTH, - -2 - ) - } -> context.string(R.string.yesterday) - else -> { - val dayFormatter = SimpleDateFormat("MMM dd", Locale.getDefault()) - dayFormatter.format(Date(this)) - } + val cal = Calendar.getInstance() + cal.timeInMillis = this + val timeFormatter = SimpleDateFormat.getTimeInstance(DateFormat.SHORT) + val time = timeFormatter.format(Date(this)) + val day = + when { + cal >= Calendar.getInstance().apply { add(Calendar.DAY_OF_MONTH, -1) } -> + context.string(R.string.today) + cal >= Calendar.getInstance().apply { add(Calendar.DAY_OF_MONTH, -2) } -> + context.string(R.string.yesterday) + else -> { + val dayFormatter = SimpleDateFormat("MMM dd", Locale.getDefault()) + dayFormatter.format(Date(this)) + } } - return context.getString(R.string.time_template, day, time) + return context.getString(R.string.time_template, day, time) } diff --git a/app/src/main/kotlin/com/pitchedapps/frost/utils/Utils.kt b/app/src/main/kotlin/com/pitchedapps/frost/utils/Utils.kt index 33d1d9d1a..4c5b45215 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/utils/Utils.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/utils/Utils.kt @@ -74,12 +74,6 @@ import com.pitchedapps.frost.injectors.JsAssets import com.pitchedapps.frost.injectors.ThemeProvider import com.pitchedapps.frost.prefs.Prefs import dagger.hilt.android.scopes.ActivityScoped -import kotlinx.coroutines.coroutineScope -import kotlinx.coroutines.launch -import org.apache.commons.text.StringEscapeUtils -import org.jsoup.Jsoup -import org.jsoup.nodes.Document -import org.jsoup.nodes.Element import java.io.File import java.io.IOException import java.net.URLEncoder @@ -87,10 +81,14 @@ import java.nio.charset.StandardCharsets import java.util.ArrayList import java.util.Locale import javax.inject.Inject +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.launch +import org.apache.commons.text.StringEscapeUtils +import org.jsoup.Jsoup +import org.jsoup.nodes.Document +import org.jsoup.nodes.Element -/** - * Created by Allan Wang on 2017-06-03. - */ +/** Created by Allan Wang on 2017-06-03. */ const val EXTRA_COOKIES = "extra_cookies" const val ARG_URL = "arg_url" const val ARG_USER_ID = "arg_user_id" @@ -99,203 +97,188 @@ const val ARG_TEXT = "arg_text" const val ARG_COOKIE = "arg_cookie" inline fun Context.launchNewTask( - cookieList: ArrayList = arrayListOf(), - clearStack: Boolean = false + cookieList: ArrayList = arrayListOf(), + clearStack: Boolean = false ) { - startActivity( - clearStack, - intentBuilder = { - putParcelableArrayListExtra(EXTRA_COOKIES, cookieList) - } - ) + startActivity( + clearStack, + intentBuilder = { putParcelableArrayListExtra(EXTRA_COOKIES, cookieList) } + ) } fun Context.launchLogin(cookieList: ArrayList, clearStack: Boolean = true) { - if (cookieList.isNotEmpty()) launchNewTask(cookieList, clearStack) - else launchNewTask(clearStack = clearStack) + if (cookieList.isNotEmpty()) launchNewTask(cookieList, clearStack) + else launchNewTask(clearStack = clearStack) } fun Activity.cookies(): ArrayList { - return intent?.getParcelableArrayListExtra(EXTRA_COOKIES) ?: arrayListOf() + return intent?.getParcelableArrayListExtra(EXTRA_COOKIES) ?: arrayListOf() } /** - * Launches the given url in a new overlay (if it already isn't in an overlay) - * Note that most requests may need to first check if the url can be launched as an overlay - * See [requestWebOverlay] to verify the launch + * Launches the given url in a new overlay (if it already isn't in an overlay) Note that most + * requests may need to first check if the url can be launched as an overlay See [requestWebOverlay] + * to verify the launch */ private inline fun Context.launchWebOverlayImpl( - url: String, - fbCookie: FbCookie, - prefs: Prefs + url: String, + fbCookie: FbCookie, + prefs: Prefs ) { - val argUrl = url.formattedFbUrl - L.v { "Launch received: $url\nLaunch web overlay: $argUrl" } - if (argUrl.isFacebookUrl && argUrl.contains("/logout.php")) { - L.d { "Logout php found" } - ctxCoroutine.launch { - fbCookie.logout(this@launchWebOverlayImpl, deleteCookie = false) - } - } else if (!(prefs.linksInDefaultApp && startActivityForUri(Uri.parse(argUrl)))) { - startActivity( - false, - intentBuilder = { - putExtra(ARG_URL, argUrl) - } - ) - } + val argUrl = url.formattedFbUrl + L.v { "Launch received: $url\nLaunch web overlay: $argUrl" } + if (argUrl.isFacebookUrl && argUrl.contains("/logout.php")) { + L.d { "Logout php found" } + ctxCoroutine.launch { fbCookie.logout(this@launchWebOverlayImpl, deleteCookie = false) } + } else if (!(prefs.linksInDefaultApp && startActivityForUri(Uri.parse(argUrl)))) { + startActivity(false, intentBuilder = { putExtra(ARG_URL, argUrl) }) + } } fun Context.launchWebOverlay(url: String, fbCookie: FbCookie, prefs: Prefs) = - launchWebOverlayImpl(url, fbCookie, prefs) + launchWebOverlayImpl(url, fbCookie, prefs) // TODO Currently, default is overlay. Switch this if default changes fun Context.launchWebOverlayDesktop(url: String, fbCookie: FbCookie, prefs: Prefs) = - launchWebOverlay(url, fbCookie, prefs) + launchWebOverlay(url, fbCookie, prefs) fun Context.launchWebOverlayMobile(url: String, fbCookie: FbCookie, prefs: Prefs) = - launchWebOverlayImpl(url, fbCookie, prefs) + launchWebOverlayImpl(url, fbCookie, prefs) -private fun Context.fadeBundle() = ActivityOptions.makeCustomAnimation( - this, - android.R.anim.fade_in, android.R.anim.fade_out -).toBundle() +private fun Context.fadeBundle() = + ActivityOptions.makeCustomAnimation(this, android.R.anim.fade_in, android.R.anim.fade_out) + .toBundle() fun Context.launchImageActivity(imageUrl: String, text: String? = null, cookie: String? = null) { - startActivity( - intentBuilder = { - putExtras(fadeBundle()) - putExtra(ARG_IMAGE_URL, imageUrl) - putExtra(ARG_TEXT, text) - putExtra(ARG_COOKIE, cookie) - } - ) + startActivity( + intentBuilder = { + putExtras(fadeBundle()) + putExtra(ARG_IMAGE_URL, imageUrl) + putExtra(ARG_TEXT, text) + putExtra(ARG_COOKIE, cookie) + } + ) } fun Activity.launchTabCustomizerActivity() { - startActivityForResult( - SettingsActivity.ACTIVITY_REQUEST_TABS, - bundleBuilder = { - with(fadeBundle()) - } - ) + startActivityForResult( + SettingsActivity.ACTIVITY_REQUEST_TABS, + bundleBuilder = { with(fadeBundle()) } + ) } fun WebOverlayActivity.url(): String { - return intent.getStringExtra(ARG_URL) ?: FbItem.FEED.url + return intent.getStringExtra(ARG_URL) ?: FbItem.FEED.url } @ActivityScoped -class ActivityThemer @Inject constructor( - private val activity: Activity, - private val prefs: Prefs, - private val themeProvider: ThemeProvider +class ActivityThemer +@Inject +constructor( + private val activity: Activity, + private val prefs: Prefs, + private val themeProvider: ThemeProvider ) { - fun setFrostTheme(forceTransparent: Boolean = false) { - val isTransparent = - forceTransparent || (Color.alpha(themeProvider.bgColor) != 255) || ( - Color.alpha( - themeProvider.headerColor - ) != 255 - ) - if (themeProvider.bgColor.isColorDark) { - activity.setTheme(if (isTransparent) R.style.FrostTheme_Transparent else R.style.FrostTheme) - } else { - activity.setTheme(if (isTransparent) R.style.FrostTheme_Light_Transparent else R.style.FrostTheme_Light) - } + fun setFrostTheme(forceTransparent: Boolean = false) { + val isTransparent = + forceTransparent || + (Color.alpha(themeProvider.bgColor) != 255) || + (Color.alpha(themeProvider.headerColor) != 255) + if (themeProvider.bgColor.isColorDark) { + activity.setTheme(if (isTransparent) R.style.FrostTheme_Transparent else R.style.FrostTheme) + } else { + activity.setTheme( + if (isTransparent) R.style.FrostTheme_Light_Transparent else R.style.FrostTheme_Light + ) } + } - fun setFrostColors(builder: ActivityThemeUtils.() -> Unit) { - val themer = ActivityThemeUtils(prefs = prefs, themeProvider = themeProvider) - themer.builder() - themer.theme(activity) - } + fun setFrostColors(builder: ActivityThemeUtils.() -> Unit) { + val themer = ActivityThemeUtils(prefs = prefs, themeProvider = themeProvider) + themer.builder() + themer.theme(activity) + } } -class ActivityThemeUtils( - private val prefs: Prefs, - private val themeProvider: ThemeProvider -) { - private var toolbar: Toolbar? = null - var themeWindow = true - private var texts = mutableListOf() - private var headers = mutableListOf() - private var backgrounds = mutableListOf() +class ActivityThemeUtils(private val prefs: Prefs, private val themeProvider: ThemeProvider) { + private var toolbar: Toolbar? = null + var themeWindow = true + private var texts = mutableListOf() + private var headers = mutableListOf() + private var backgrounds = mutableListOf() - fun toolbar(toolbar: Toolbar) { - this.toolbar = toolbar - } + fun toolbar(toolbar: Toolbar) { + this.toolbar = toolbar + } - fun text(vararg views: TextView) { - texts.addAll(views) - } + fun text(vararg views: TextView) { + texts.addAll(views) + } - fun header(vararg views: View) { - headers.addAll(views) - } + fun header(vararg views: View) { + headers.addAll(views) + } - fun background(vararg views: View) { - backgrounds.addAll(views) - } + fun background(vararg views: View) { + backgrounds.addAll(views) + } - fun theme(activity: Activity) { - with(activity) { - statusBarColor = themeProvider.headerColor.darken(0.1f).withAlpha(255) - if (prefs.tintNavBar) navigationBarColor = themeProvider.headerColor - if (themeWindow) window.setBackgroundDrawable(ColorDrawable(themeProvider.bgColor)) - toolbar?.setBackgroundColor(themeProvider.headerColor) - toolbar?.setTitleTextColor(themeProvider.iconColor) - toolbar?.overflowIcon?.setTint(themeProvider.iconColor) - texts.forEach { it.setTextColor(themeProvider.textColor) } - headers.forEach { it.setBackgroundColor(themeProvider.headerColor) } - backgrounds.forEach { it.setBackgroundColor(themeProvider.bgColor) } - } + fun theme(activity: Activity) { + with(activity) { + statusBarColor = themeProvider.headerColor.darken(0.1f).withAlpha(255) + if (prefs.tintNavBar) navigationBarColor = themeProvider.headerColor + if (themeWindow) window.setBackgroundDrawable(ColorDrawable(themeProvider.bgColor)) + toolbar?.setBackgroundColor(themeProvider.headerColor) + toolbar?.setTitleTextColor(themeProvider.iconColor) + toolbar?.overflowIcon?.setTint(themeProvider.iconColor) + texts.forEach { it.setTextColor(themeProvider.textColor) } + headers.forEach { it.setBackgroundColor(themeProvider.headerColor) } + backgrounds.forEach { it.setBackgroundColor(themeProvider.bgColor) } } + } } fun frostEvent(name: String, vararg events: Pair) { - // todo bind - L.v { "Event: $name ${events.joinToString(", ")}" } + // todo bind + L.v { "Event: $name ${events.joinToString(", ")}" } } -/** - * Helper method to quietly keep track of throwable issues - */ +/** Helper method to quietly keep track of throwable issues */ fun Throwable?.logFrostEvent(text: String) { - val msg = if (this == null) text else "$text: $message" - L.e { msg } - frostEvent("Errors", "text" to text, "message" to (this?.message ?: "NA")) + val msg = if (this == null) text else "$text: $message" + L.e { msg } + frostEvent("Errors", "text" to text, "message" to (this?.message ?: "NA")) } fun Activity.frostSnackbar( - @StringRes text: Int, - themeProvider: ThemeProvider, - builder: Snackbar.() -> Unit = {} + @StringRes text: Int, + themeProvider: ThemeProvider, + builder: Snackbar.() -> Unit = {} ) = snackbar(text, Snackbar.LENGTH_LONG, frostSnackbar(themeProvider, builder)) fun View.frostSnackbar( - @StringRes text: Int, - themeProvider: ThemeProvider, - builder: Snackbar.() -> Unit = {} + @StringRes text: Int, + themeProvider: ThemeProvider, + builder: Snackbar.() -> Unit = {} ) = snackbar(text, Snackbar.LENGTH_LONG, frostSnackbar(themeProvider, builder)) @SuppressLint("RestrictedApi") private inline fun frostSnackbar( - themeProvider: ThemeProvider, - crossinline builder: Snackbar.() -> Unit + themeProvider: ThemeProvider, + crossinline builder: Snackbar.() -> Unit ): Snackbar.() -> Unit = { - builder() - // hacky workaround, but it has proper checks and shouldn't crash - ((view as? FrameLayout)?.getChildAt(0) as? SnackbarContentLayout)?.apply { - messageView.setTextColor(themeProvider.textColor) - actionView.setTextColor(themeProvider.accentColor) - // only set if previous text colors are set - view.setBackgroundColor(themeProvider.bgColor.withAlpha(255).colorToForeground(0.1f)) - } + builder() + // hacky workaround, but it has proper checks and shouldn't crash + ((view as? FrameLayout)?.getChildAt(0) as? SnackbarContentLayout)?.apply { + messageView.setTextColor(themeProvider.textColor) + actionView.setTextColor(themeProvider.accentColor) + // only set if previous text colors are set + view.setBackgroundColor(themeProvider.bgColor.withAlpha(255).colorToForeground(0.1f)) + } } fun Activity.frostNavigationBar(prefs: Prefs, themeProvider: ThemeProvider) { - navigationBarColor = if (prefs.tintNavBar) themeProvider.headerColor else Color.BLACK + navigationBarColor = if (prefs.tintNavBar) themeProvider.headerColor else Color.BLACK } @Throws(IOException::class) @@ -305,189 +288,167 @@ fun createMediaFile(extension: String) = createMediaFile("Frost", extension) fun Context.createPrivateMediaFile(extension: String) = createPrivateMediaFile("Frost", extension) /** - * Tries to send the uri to the proper activity via an intent - * returns [true] if activity is resolved, [false] otherwise - * For safety, any uri that ([isFacebookUrl] or [isMessengerUrl]) without [isExplicitIntent] will return [false] + * Tries to send the uri to the proper activity via an intent returns [true] if activity is + * resolved, [false] otherwise For safety, any uri that ([isFacebookUrl] or [isMessengerUrl]) + * without [isExplicitIntent] will return [false] */ fun Context.startActivityForUri(uri: Uri): Boolean { - val url = uri.toString() - if ((url.isFacebookUrl || url.isMessengerUrl) && !url.isExplicitIntent) { - return false - } - val intent = Intent( - Intent.ACTION_VIEW, - uri.formattedFbUri - ) - return try { - startActivity(intent) - true - } catch (e: ActivityNotFoundException) { - false - } + val url = uri.toString() + if ((url.isFacebookUrl || url.isMessengerUrl) && !url.isExplicitIntent) { + return false + } + val intent = Intent(Intent.ACTION_VIEW, uri.formattedFbUri) + return try { + startActivity(intent) + true + } catch (e: ActivityNotFoundException) { + false + } } -/** - * [true] if url contains [FACEBOOK_COM] - */ +/** [true] if url contains [FACEBOOK_COM] */ inline val String?.isFacebookUrl - get() = this != null && (contains(FACEBOOK_COM) || contains(FBCDN_NET)) + get() = this != null && (contains(FACEBOOK_COM) || contains(FBCDN_NET)) inline val String?.isMessengerUrl - get() = this != null && contains(MESSENGER_COM) + get() = this != null && contains(MESSENGER_COM) inline val String?.isFbCookie - get() = this != null && contains("c_user") + get() = this != null && contains("c_user") -/** - * [true] if url is a video and can be accepted by VideoViewer - */ +/** [true] if url is a video and can be accepted by VideoViewer */ inline val String.isVideoUrl - get() = startsWith(VIDEO_REDIRECT) || - (startsWith("https://video-") && contains(FBCDN_NET)) + get() = startsWith(VIDEO_REDIRECT) || (startsWith("https://video-") && contains(FBCDN_NET)) -/** - * [true] if url directly leads to a usable image - */ +/** [true] if url directly leads to a usable image */ inline val String.isImageUrl: Boolean - get() { - return contains(FBCDN_NET) && (contains(".png") || contains(".jpg")) - } + get() { + return contains(FBCDN_NET) && (contains(".png") || contains(".jpg")) + } -/** - * [true] if url can be retrieved to get a direct image url - */ +/** [true] if url can be retrieved to get a direct image url */ inline val String.isIndirectImageUrl: Boolean - get() { - return contains("/photo/view_full_size/") && contains("fbid=") - } + get() { + return contains("/photo/view_full_size/") && contains("fbid=") + } -/** - * [true] if url can be displayed in a different webview - */ +/** [true] if url can be displayed in a different webview */ inline val String?.isIndependent: Boolean - get() { - if (this == null || length < 5) return false // ignore short queries - if (this[0] == '#' && !contains('/')) return false // ignore element values - if (startsWith("http") && !isFacebookUrl) return true // ignore non facebook urls - if (dependentSegments.any { contains(it) }) return false // ignore known dependent segments - return true - } + get() { + if (this == null || length < 5) return false // ignore short queries + if (this[0] == '#' && !contains('/')) return false // ignore element values + if (startsWith("http") && !isFacebookUrl) return true // ignore non facebook urls + if (dependentSegments.any { contains(it) }) return false // ignore known dependent segments + return true + } -val dependentSegments = arrayOf( - "photoset_token", "direct_action_execute", "messages/?pageNum", "sharer.php", - "events/permalink", "events/feed/watch", +val dependentSegments = + arrayOf( + "photoset_token", + "direct_action_execute", + "messages/?pageNum", + "sharer.php", + "events/permalink", + "events/feed/watch", /* * Add new members to groups * * No longer dependent again as of 12/20/2018 */ // "madminpanel", - /** - * Editing images - */ + /** Editing images */ "/confirmation/?", - /** - * Remove entry from "people you may know" - */ + /** Remove entry from "people you may know" */ "/pymk/xout/", /* * Facebook messages have the following cases for the tid query * mid* or id* for newer threads, which can be launched in new windows * or a hash for old threads, which must be loaded on old threads */ - "messages/read/?tid=id", "messages/read/?tid=mid", + "messages/read/?tid=id", + "messages/read/?tid=mid", // For some reason townhall doesn't load independently // This will allow it to load, but going back unfortunately messes up the menu client // See https://github.com/AllanWang/Frost-for-Facebook/issues/1593 "/townhall/" -) + ) inline val String?.isExplicitIntent - get() = this != null && (startsWith("intent://") || startsWith("market://")) + get() = this != null && (startsWith("intent://") || startsWith("market://")) -fun String.urlEncode(): String = - URLEncoder.encode(this, StandardCharsets.UTF_8.name()) +fun String.urlEncode(): String = URLEncoder.encode(this, StandardCharsets.UTF_8.name()) fun Context.frostChangelog() = showChangelog(R.xml.frost_changelog) fun Context.frostUriFromFile(file: File): Uri = - FileProvider.getUriForFile( - this, - BuildConfig.APPLICATION_ID + ".provider", - file - ) + FileProvider.getUriForFile(this, BuildConfig.APPLICATION_ID + ".provider", file) -/** - * Gets uri from our own resolver if it's a file, or return the parsed uri otherwise - */ +/** Gets uri from our own resolver if it's a file, or return the parsed uri otherwise */ fun Context.frostUri(entry: String): Uri { - val uri = Uri.parse(entry) - val path = uri.path - if (uri.scheme == "file" && path != null) { - return frostUriFromFile(File(path)) - } - return uri + val uri = Uri.parse(entry) + val path = uri.path + if (uri.scheme == "file" && path != null) { + return frostUriFromFile(File(path)) + } + return uri } inline fun Context.sendFrostEmail( - @StringRes subjectId: Int, - prefs: Prefs, - crossinline builder: EmailBuilder.() -> Unit + @StringRes subjectId: Int, + prefs: Prefs, + crossinline builder: EmailBuilder.() -> Unit ) = sendFrostEmail(string(subjectId), prefs, builder) inline fun Context.sendFrostEmail( - subjectId: String, - prefs: Prefs, - crossinline builder: EmailBuilder.() -> Unit -) = sendEmail("", subjectId) { + subjectId: String, + prefs: Prefs, + crossinline builder: EmailBuilder.() -> Unit +) = + sendEmail("", subjectId) { builder() addFrostDetails(prefs) -} + } fun EmailBuilder.addFrostDetails(prefs: Prefs) { - addItem("Prev version", prefs.prevVersionCode.toString()) - val proTag = "FO" - addItem("Random Frost ID", "${prefs.frostId}-$proTag") - addItem("Locale", Locale.getDefault().displayName) + addItem("Prev version", prefs.prevVersionCode.toString()) + val proTag = "FO" + addItem("Random Frost ID", "${prefs.frostId}-$proTag") + addItem("Locale", Locale.getDefault().displayName) } fun frostJsoup(cookie: String?, url: String): Document = - Jsoup.connect(url).run { - if (cookie.isNullOrBlank()) this - else cookie(FACEBOOK_COM, cookie) - }.userAgent(USER_AGENT).get() + Jsoup.connect(url) + .run { if (cookie.isNullOrBlank()) this else cookie(FACEBOOK_COM, cookie) } + .userAgent(USER_AGENT) + .get() fun Element.first(vararg select: String): Element? { - select.forEach { - val e = select(it) - if (e.size > 0) return e.first() - } - return null + select.forEach { + val e = select(it) + if (e.size > 0) return e.first() + } + return null } fun File.createFreshFile(): Boolean { - if (exists()) { - if (!delete()) return false - } else { - val parent = parentFile - if (parent != null && !parent.exists() && !parent.mkdirs()) - return false - } - return createNewFile() + if (exists()) { + if (!delete()) return false + } else { + val parent = parentFile + if (parent != null && !parent.exists() && !parent.mkdirs()) return false + } + return createNewFile() } fun File.createFreshDir(): Boolean { - if (exists() && !deleteRecursively()) - return false - return mkdirs() + if (exists() && !deleteRecursively()) return false + return mkdirs() } fun String.unescapeHtml(): String = - StringEscapeUtils.unescapeXml(this) - .replace("\\u003C", "<") - .replace("\\\"", "\"") + StringEscapeUtils.unescapeXml(this).replace("\\u003C", "<").replace("\\\"", "\"") suspend fun Context.loadAssets(themeProvider: ThemeProvider): Unit = coroutineScope { - themeProvider.preload() - JsAssets.load(this@loadAssets) + themeProvider.preload() + JsAssets.load(this@loadAssets) } diff --git a/app/src/main/kotlin/com/pitchedapps/frost/utils/WebContextMenu.kt b/app/src/main/kotlin/com/pitchedapps/frost/utils/WebContextMenu.kt index f3c815781..5eb2dc534 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/utils/WebContextMenu.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/utils/WebContextMenu.kt @@ -29,58 +29,55 @@ import com.pitchedapps.frost.facebook.FbCookie import com.pitchedapps.frost.facebook.formattedFbUrl import com.pitchedapps.frost.prefs.Prefs -/** - * Created by Allan Wang on 2017-07-07. - */ +/** Created by Allan Wang on 2017-07-07. */ fun Context.showWebContextMenu(wc: WebContext, fbCookie: FbCookie, prefs: Prefs) { - if (wc.isEmpty) return - var title = wc.url ?: string(R.string.menu) - title = - title.substring(title.indexOf("m/") + 1) // just so if defaults to 0 in case it's not .com/ - if (title.length > 100) title = title.substring(0, 100) + '\u2026' + if (wc.isEmpty) return + var title = wc.url ?: string(R.string.menu) + title = + title.substring(title.indexOf("m/") + 1) // just so if defaults to 0 in case it's not .com/ + if (title.length > 100) title = title.substring(0, 100) + '\u2026' - val menuItems = WebContextType.values - .filter { it.constraint(wc) } + val menuItems = WebContextType.values.filter { it.constraint(wc) } - materialDialog { - title(text = title) - listItems(items = menuItems.map { string(it.textId) }) { _, position, _ -> - menuItems[position].onClick(this@showWebContextMenu, wc, fbCookie, prefs) - } - onDismiss { - // showing the dialog interrupts the touch down event, so we must ensure that the viewpager's swipe is enabled - (this@showWebContextMenu as? MainActivity) - ?.contentBinding - ?.viewpager - ?.enableSwipe = true - } + materialDialog { + title(text = title) + listItems(items = menuItems.map { string(it.textId) }) { _, position, _ -> + menuItems[position].onClick(this@showWebContextMenu, wc, fbCookie, prefs) } + onDismiss { + // showing the dialog interrupts the touch down event, so we must ensure that the viewpager's + // swipe is enabled + (this@showWebContextMenu as? MainActivity)?.contentBinding?.viewpager?.enableSwipe = true + } + } } class WebContext(val unformattedUrl: String?, val text: String?) { - val url: String? = unformattedUrl?.formattedFbUrl - inline val hasUrl get() = unformattedUrl != null - inline val hasText get() = text != null - inline val isEmpty get() = !hasUrl && !hasText + val url: String? = unformattedUrl?.formattedFbUrl + inline val hasUrl + get() = unformattedUrl != null + inline val hasText + get() = text != null + inline val isEmpty + get() = !hasUrl && !hasText } enum class WebContextType( - val textId: Int, - val constraint: (wc: WebContext) -> Boolean, - val onClick: (c: Context, wc: WebContext, fc: FbCookie, prefs: Prefs) -> Unit + val textId: Int, + val constraint: (wc: WebContext) -> Boolean, + val onClick: (c: Context, wc: WebContext, fc: FbCookie, prefs: Prefs) -> Unit ) { - OPEN_LINK( - R.string.open_link, - { it.hasUrl }, - { c, wc, fc, prefs -> c.launchWebOverlay(wc.url!!, fc, prefs) } - ), - COPY_LINK(R.string.copy_link, { it.hasUrl }, { c, wc, _, _ -> c.copyToClipboard(wc.url) }), - COPY_TEXT(R.string.copy_text, { it.hasText }, { c, wc, _, _ -> c.copyToClipboard(wc.text) }), - SHARE_LINK(R.string.share_link, { it.hasUrl }, { c, wc, _, _ -> c.shareText(wc.url) }) - ; + OPEN_LINK( + R.string.open_link, + { it.hasUrl }, + { c, wc, fc, prefs -> c.launchWebOverlay(wc.url!!, fc, prefs) } + ), + COPY_LINK(R.string.copy_link, { it.hasUrl }, { c, wc, _, _ -> c.copyToClipboard(wc.url) }), + COPY_TEXT(R.string.copy_text, { it.hasText }, { c, wc, _, _ -> c.copyToClipboard(wc.text) }), + SHARE_LINK(R.string.share_link, { it.hasUrl }, { c, wc, _, _ -> c.shareText(wc.url) }); - companion object { - val values = values() - operator fun get(index: Int) = values[index] - } + companion object { + val values = values() + operator fun get(index: Int) = values[index] + } } diff --git a/app/src/main/kotlin/com/pitchedapps/frost/views/AccountItem.kt b/app/src/main/kotlin/com/pitchedapps/frost/views/AccountItem.kt index e15538b87..bcd592505 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/views/AccountItem.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/views/AccountItem.kt @@ -39,68 +39,69 @@ import com.pitchedapps.frost.glide.FrostGlide import com.pitchedapps.frost.glide.GlideApp import com.pitchedapps.frost.injectors.ThemeProvider -/** - * Created by Allan Wang on 2017-06-05. - */ -class AccountItem( - val cookie: CookieEntity?, - private val themeProvider: ThemeProvider -) : KauIItem(R.layout.view_account, { ViewHolder(it) }, R.id.item_account) { +/** Created by Allan Wang on 2017-06-05. */ +class AccountItem(val cookie: CookieEntity?, private val themeProvider: ThemeProvider) : + KauIItem(R.layout.view_account, { ViewHolder(it) }, R.id.item_account) { - override fun bindView(holder: ViewHolder, payloads: List) { - super.bindView(holder, payloads) - with(holder) { - text.invisible() - text.setTextColor(themeProvider.textColor) - if (cookie != null) { - text.text = cookie.name - GlideApp.with(itemView).load(profilePictureUrl(cookie.id)) - .transform(FrostGlide.circleCrop).listener(object : RequestListener { - override fun onResourceReady( - resource: Drawable?, - model: Any?, - target: Target?, - dataSource: DataSource?, - isFirstResource: Boolean - ): Boolean { - text.fadeIn() - return false - } + override fun bindView(holder: ViewHolder, payloads: List) { + super.bindView(holder, payloads) + with(holder) { + text.invisible() + text.setTextColor(themeProvider.textColor) + if (cookie != null) { + text.text = cookie.name + GlideApp.with(itemView) + .load(profilePictureUrl(cookie.id)) + .transform(FrostGlide.circleCrop) + .listener( + object : RequestListener { + override fun onResourceReady( + resource: Drawable?, + model: Any?, + target: Target?, + dataSource: DataSource?, + isFirstResource: Boolean + ): Boolean { + text.fadeIn() + return false + } - override fun onLoadFailed( - e: GlideException?, - model: Any?, - target: Target?, - isFirstResource: Boolean - ): Boolean { - text.fadeIn() - return false - } - }).into(image) - } else { - text.visible() - image.setImageDrawable( - GoogleMaterial.Icon.gmd_add_circle_outline.toDrawable( - itemView.context, - 100, - themeProvider.textColor - ) - ) - text.text = itemView.context.getString(R.string.kau_add_account) + override fun onLoadFailed( + e: GlideException?, + model: Any?, + target: Target?, + isFirstResource: Boolean + ): Boolean { + text.fadeIn() + return false + } } - } + ) + .into(image) + } else { + text.visible() + image.setImageDrawable( + GoogleMaterial.Icon.gmd_add_circle_outline.toDrawable( + itemView.context, + 100, + themeProvider.textColor + ) + ) + text.text = itemView.context.getString(R.string.kau_add_account) + } } + } - override fun unbindView(holder: ViewHolder) { - super.unbindView(holder) - with(holder) { - text.text = null - image.setImageDrawable(null) - } + override fun unbindView(holder: ViewHolder) { + super.unbindView(holder) + with(holder) { + text.text = null + image.setImageDrawable(null) } + } - class ViewHolder(view: View) : RecyclerView.ViewHolder(view) { - val image: ImageView by bindView(R.id.account_image) - val text: AppCompatTextView by bindView(R.id.account_text) - } + class ViewHolder(view: View) : RecyclerView.ViewHolder(view) { + val image: ImageView by bindView(R.id.account_image) + val text: AppCompatTextView by bindView(R.id.account_text) + } } diff --git a/app/src/main/kotlin/com/pitchedapps/frost/views/BadgedIcon.kt b/app/src/main/kotlin/com/pitchedapps/frost/views/BadgedIcon.kt index 61be271f8..2f66b000c 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/views/BadgedIcon.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/views/BadgedIcon.kt @@ -34,68 +34,58 @@ import com.pitchedapps.frost.prefs.Prefs import dagger.hilt.android.AndroidEntryPoint import javax.inject.Inject -/** - * Created by Allan Wang on 2017-06-19. - */ +/** Created by Allan Wang on 2017-06-19. */ @AndroidEntryPoint -class BadgedIcon @JvmOverloads constructor( - context: Context, - attrs: AttributeSet? = null, - defStyleAttr: Int = 0 -) : ConstraintLayout(context, attrs, defStyleAttr) { +class BadgedIcon +@JvmOverloads +constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0) : + ConstraintLayout(context, attrs, defStyleAttr) { - @Inject - lateinit var prefs: Prefs + @Inject lateinit var prefs: Prefs - @Inject - lateinit var themeProvider: ThemeProvider + @Inject lateinit var themeProvider: ThemeProvider - private val binding: ViewBadgedIconBinding = - ViewBadgedIconBinding.inflate(LayoutInflater.from(context), this, true) + private val binding: ViewBadgedIconBinding = + ViewBadgedIconBinding.inflate(LayoutInflater.from(context), this, true) - init { - binding.init() + init { + binding.init() + } + + private fun ViewBadgedIconBinding.init() { + val badgeColor = + prefs.mainActivityLayout.backgroundColor(themeProvider).withAlpha(255).colorToForeground(0.2f) + val badgeBackground = + GradientDrawable(GradientDrawable.Orientation.BOTTOM_TOP, intArrayOf(badgeColor, badgeColor)) + badgeBackground.cornerRadius = 13.dpToPx.toFloat() + badgeText.background = badgeBackground + badgeText.setTextColor(prefs.mainActivityLayout.iconColor(themeProvider)) + } + + var iicon: IIcon? = null + set(value) { + field = value + binding.badgeImage.setImageDrawable( + value?.toDrawable( + context, + sizeDp = 20, + color = prefs.mainActivityLayout.iconColor(themeProvider) + ) + ) } - private fun ViewBadgedIconBinding.init() { - val badgeColor = - prefs.mainActivityLayout.backgroundColor(themeProvider).withAlpha(255) - .colorToForeground(0.2f) - val badgeBackground = - GradientDrawable( - GradientDrawable.Orientation.BOTTOM_TOP, - intArrayOf(badgeColor, badgeColor) - ) - badgeBackground.cornerRadius = 13.dpToPx.toFloat() - badgeText.background = badgeBackground - badgeText.setTextColor(prefs.mainActivityLayout.iconColor(themeProvider)) + fun setAllAlpha(alpha: Float) { + // badgeTextView.setTextColor(themeProvider.textColor.withAlpha(alpha.toInt())) + binding.badgeImage.drawable.alpha = alpha.toInt() + } + + var badgeText: String? + get() = binding.badgeText.text.toString() + set(value) { + with(binding) { + if (badgeText.text == value) return + badgeText.text = value + if (value != null && value != "0") badgeText.visible() else badgeText.gone() + } } - - var iicon: IIcon? = null - set(value) { - field = value - binding.badgeImage.setImageDrawable( - value?.toDrawable( - context, - sizeDp = 20, - color = prefs.mainActivityLayout.iconColor(themeProvider) - ) - ) - } - - fun setAllAlpha(alpha: Float) { - // badgeTextView.setTextColor(themeProvider.textColor.withAlpha(alpha.toInt())) - binding.badgeImage.drawable.alpha = alpha.toInt() - } - - var badgeText: String? - get() = binding.badgeText.text.toString() - set(value) { - with(binding) { - if (badgeText.text == value) return - badgeText.text = value - if (value != null && value != "0") badgeText.visible() - else badgeText.gone() - } - } } diff --git a/app/src/main/kotlin/com/pitchedapps/frost/views/DragFrame.kt b/app/src/main/kotlin/com/pitchedapps/frost/views/DragFrame.kt index 87ad2bef7..34267db40 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/views/DragFrame.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/views/DragFrame.kt @@ -26,70 +26,69 @@ import android.widget.FrameLayout import androidx.core.view.ViewCompat import androidx.customview.widget.ViewDragHelper -class DragFrame @JvmOverloads constructor( - context: Context, - attrs: AttributeSet? = null, - defStyleAttr: Int = 0 -) : FrameLayout(context, attrs, defStyleAttr) { +class DragFrame +@JvmOverloads +constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0) : + FrameLayout(context, attrs, defStyleAttr) { - var dragHelper: ViewDragHelper? = null - var viewToIgnore: View? = null - private val rect = Rect() - private val location = IntArray(2) - private var shouldIgnore: Boolean = false + var dragHelper: ViewDragHelper? = null + var viewToIgnore: View? = null + private val rect = Rect() + private val location = IntArray(2) + private var shouldIgnore: Boolean = false - override fun onInterceptTouchEvent(event: MotionEvent): Boolean { - if (event.actionMasked == MotionEvent.ACTION_DOWN) { - shouldIgnore = shouldIgnore(event) - } - if (shouldIgnore) { - return false - } - return try { - dragHelper?.shouldInterceptTouchEvent(event) ?: false - } catch (e: Exception) { - false - } + override fun onInterceptTouchEvent(event: MotionEvent): Boolean { + if (event.actionMasked == MotionEvent.ACTION_DOWN) { + shouldIgnore = shouldIgnore(event) } - - @SuppressLint("ClickableViewAccessibility") - override fun onTouchEvent(event: MotionEvent): Boolean { - if (event.actionMasked == MotionEvent.ACTION_DOWN) { - shouldIgnore = shouldIgnore(event) - } - if (shouldIgnore) { - return false - } - try { - dragHelper?.processTouchEvent(event) ?: return false - } catch (e: Exception) { - return false - } - return true + if (shouldIgnore) { + return false } - - override fun dispatchTouchEvent(event: MotionEvent): Boolean { - if (event.actionMasked == MotionEvent.ACTION_DOWN) { - shouldIgnore = shouldIgnore(event) - } - if (shouldIgnore) { - return false - } - return super.dispatchTouchEvent(event) + return try { + dragHelper?.shouldInterceptTouchEvent(event) ?: false + } catch (e: Exception) { + false } + } - private fun shouldIgnore(event: MotionEvent): Boolean { - val v = viewToIgnore ?: return false - v.getDrawingRect(rect) - v.getLocationOnScreen(location) - rect.offset(location[0], location[1]) - return rect.contains(event.rawX.toInt(), event.rawY.toInt()) + @SuppressLint("ClickableViewAccessibility") + override fun onTouchEvent(event: MotionEvent): Boolean { + if (event.actionMasked == MotionEvent.ACTION_DOWN) { + shouldIgnore = shouldIgnore(event) } + if (shouldIgnore) { + return false + } + try { + dragHelper?.processTouchEvent(event) ?: return false + } catch (e: Exception) { + return false + } + return true + } - override fun computeScroll() { - super.computeScroll() - if (dragHelper?.continueSettling(true) == true) { - ViewCompat.postInvalidateOnAnimation(this) - } + override fun dispatchTouchEvent(event: MotionEvent): Boolean { + if (event.actionMasked == MotionEvent.ACTION_DOWN) { + shouldIgnore = shouldIgnore(event) } + if (shouldIgnore) { + return false + } + return super.dispatchTouchEvent(event) + } + + private fun shouldIgnore(event: MotionEvent): Boolean { + val v = viewToIgnore ?: return false + v.getDrawingRect(rect) + v.getLocationOnScreen(location) + rect.offset(location[0], location[1]) + return rect.contains(event.rawX.toInt(), event.rawY.toInt()) + } + + override fun computeScroll() { + super.computeScroll() + if (dragHelper?.continueSettling(true) == true) { + ViewCompat.postInvalidateOnAnimation(this) + } + } } diff --git a/app/src/main/kotlin/com/pitchedapps/frost/views/FrostContentView.kt b/app/src/main/kotlin/com/pitchedapps/frost/views/FrostContentView.kt index 16c28c024..144a2f920 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/views/FrostContentView.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/views/FrostContentView.kt @@ -43,6 +43,7 @@ import com.pitchedapps.frost.utils.L import com.pitchedapps.frost.web.FrostEmitter import com.pitchedapps.frost.web.asFrostEmitter import dagger.hilt.android.AndroidEntryPoint +import javax.inject.Inject import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.channels.BufferOverflow @@ -56,231 +57,234 @@ import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.runningFold import kotlinx.coroutines.flow.transformWhile -import javax.inject.Inject @ExperimentalCoroutinesApi -class FrostContentWeb @JvmOverloads constructor( - context: Context, - attrs: AttributeSet? = null, - defStyleAttr: Int = 0, - defStyleRes: Int = 0 +class FrostContentWeb +@JvmOverloads +constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0, + defStyleRes: Int = 0 ) : FrostContentView(context, attrs, defStyleAttr, defStyleRes) { - override val layoutRes: Int = R.layout.view_content_base_web + override val layoutRes: Int = R.layout.view_content_base_web } @ExperimentalCoroutinesApi -class FrostContentRecycler @JvmOverloads constructor( - context: Context, - attrs: AttributeSet? = null, - defStyleAttr: Int = 0, - defStyleRes: Int = 0 +class FrostContentRecycler +@JvmOverloads +constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0, + defStyleRes: Int = 0 ) : FrostContentView(context, attrs, defStyleAttr, defStyleRes) { - override val layoutRes: Int = R.layout.view_content_base_recycler + override val layoutRes: Int = R.layout.view_content_base_recycler } @ExperimentalCoroutinesApi -abstract class FrostContentView @JvmOverloads constructor( - context: Context, - attrs: AttributeSet? = null, - defStyleAttr: Int = 0, - defStyleRes: Int = 0 -) : FrostContentViewBase(context, attrs, defStyleAttr, defStyleRes), - FrostContentParent where T : View, T : FrostContentCore { +abstract class FrostContentView +@JvmOverloads +constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0, + defStyleRes: Int = 0 +) : FrostContentViewBase(context, attrs, defStyleAttr, defStyleRes), FrostContentParent where +T : View, +T : FrostContentCore { - val coreView: T by bindView(R.id.content_core) + val coreView: T by bindView(R.id.content_core) - override val core: FrostContentCore - get() = coreView + override val core: FrostContentCore + get() = coreView } -/** - * Subsection of [FrostContentView] that is [AndroidEntryPoint] friendly (no generics) - */ +/** Subsection of [FrostContentView] that is [AndroidEntryPoint] friendly (no generics) */ @AndroidEntryPoint @ExperimentalCoroutinesApi abstract class FrostContentViewBase( + context: Context, + attrs: AttributeSet?, + defStyleAttr: Int, + defStyleRes: Int +) : FrameLayout(context, attrs, defStyleAttr, defStyleRes), FrostContentParent { + + // No JvmOverloads due to hilt + constructor(context: Context) : this(context, null) + + constructor(context: Context, attrs: AttributeSet?) : this(context, attrs, 0) + + constructor( context: Context, attrs: AttributeSet?, - defStyleAttr: Int, - defStyleRes: Int -) : FrameLayout(context, attrs, defStyleAttr, defStyleRes), - FrostContentParent { + defStyleAttr: Int + ) : this(context, attrs, defStyleAttr, 0) - // No JvmOverloads due to hilt - constructor(context: Context) : this(context, null) + @Inject lateinit var prefs: Prefs - constructor(context: Context, attrs: AttributeSet?) : this(context, attrs, 0) + @Inject lateinit var themeProvider: ThemeProvider - constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : this( - context, - attrs, - defStyleAttr, - 0 + private val refresh: SwipeRefreshLayout by bindView(R.id.content_refresh) + private val progress: ProgressBar by bindView(R.id.content_progress) + + private val coreView: View by bindView(R.id.content_core) + + /** + * While this can be conflated, there exist situations where we wish to watch refresh cycles. + * Here, we'd need to make sure we don't skip events + * + * TODO ensure there is only one flow provider is this is still separated in login Use case for + * shared flow is to avoid emitting before subscribing; buffer can probably be size 1 + */ + private val refreshMutableFlow = + MutableSharedFlow( + extraBufferCapacity = 10, + onBufferOverflow = BufferOverflow.DROP_OLDEST ) - @Inject - lateinit var prefs: Prefs + override val refreshFlow: SharedFlow = refreshMutableFlow.asSharedFlow() - @Inject - lateinit var themeProvider: ThemeProvider + override val refreshEmit: FrostEmitter = refreshMutableFlow.asFrostEmitter() - private val refresh: SwipeRefreshLayout by bindView(R.id.content_refresh) - private val progress: ProgressBar by bindView(R.id.content_progress) + private val progressMutableFlow = MutableStateFlow(0) - private val coreView: View by bindView(R.id.content_core) + override val progressFlow: SharedFlow = progressMutableFlow.asSharedFlow() - /** - * While this can be conflated, there exist situations where we wish to watch refresh cycles. - * Here, we'd need to make sure we don't skip events - * - * TODO ensure there is only one flow provider is this is still separated in login - * Use case for shared flow is to avoid emitting before subscribing; buffer can probably be size 1 - */ - private val refreshMutableFlow = MutableSharedFlow( - extraBufferCapacity = 10, - onBufferOverflow = BufferOverflow.DROP_OLDEST - ) + override val progressEmit: FrostEmitter = progressMutableFlow.asFrostEmitter() - override val refreshFlow: SharedFlow = refreshMutableFlow.asSharedFlow() + private val titleMutableFlow = MutableStateFlow("") - override val refreshEmit: FrostEmitter = refreshMutableFlow.asFrostEmitter() + override val titleFlow: SharedFlow = titleMutableFlow.asSharedFlow() - private val progressMutableFlow = MutableStateFlow(0) + override val titleEmit: FrostEmitter = titleMutableFlow.asFrostEmitter() - override val progressFlow: SharedFlow = progressMutableFlow.asSharedFlow() + override lateinit var scope: CoroutineScope - override val progressEmit: FrostEmitter = progressMutableFlow.asFrostEmitter() + override lateinit var baseUrl: String + override var baseEnum: FbItem? = null - private val titleMutableFlow = MutableStateFlow("") + protected abstract val layoutRes: Int - override val titleFlow: SharedFlow = titleMutableFlow.asSharedFlow() + @Volatile + override var swipeDisabledByAction = false + set(value) { + field = value + updateSwipeEnabler() + } - override val titleEmit: FrostEmitter = titleMutableFlow.asFrostEmitter() + @Volatile + override var swipeAllowedByPage: Boolean = true + set(value) { + field = value + updateSwipeEnabler() + } - override lateinit var scope: CoroutineScope + private fun updateSwipeEnabler() { + val swipeEnabled = swipeAllowedByPage && !swipeDisabledByAction + if (refresh.isEnabled == swipeEnabled) return + refresh.post { refresh.isEnabled = swipeEnabled } + } - override lateinit var baseUrl: String - override var baseEnum: FbItem? = null + /** Sets up everything Called by [bind] */ + protected fun init() { + inflate(context, layoutRes, this) + reloadThemeSelf() + } - protected abstract val layoutRes: Int + override fun bind(container: FrostContentContainer) { + baseUrl = container.baseUrl + baseEnum = container.baseEnum + init() + scope = container + core.bind(this, container) + refresh.setOnRefreshListener { core.reload(true) } - @Volatile - override var swipeDisabledByAction = false - set(value) { - field = value - updateSwipeEnabler() + refreshFlow + .distinctUntilChanged() + .onEach { r -> + L.v { "Refreshing $r" } + refresh.isRefreshing = r + } + .launchIn(scope) + + progressFlow + .onEach { p -> + progress.invisibleIf(p == 100) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) progress.setProgress(p, true) + else progress.progress = p + } + .launchIn(scope) + } + + override fun reloadTheme() { + reloadThemeSelf() + core.reloadTheme() + } + + override fun reloadTextSize() { + core.reloadTextSize() + } + + override fun reloadThemeSelf() { + progress.tint(themeProvider.textColor.withAlpha(180)) + refresh.setColorSchemeColors(themeProvider.iconColor) + refresh.setProgressBackgroundColorSchemeColor(themeProvider.headerColor.withAlpha(255)) + } + + override fun reloadTextSizeSelf() { + // intentionally blank + } + + override fun destroy() { + core.destroy() + } + + private var transitionStart: Long = -1 + + /** + * Hook onto the refresh observable for one cycle Animate toggles between the fancy ripple and the + * basic fade The cycle only starts on the first load since there may have been another process + * when this is registered + */ + override fun registerTransition(urlChanged: Boolean, animate: Boolean): Boolean { + if (!urlChanged && transitionStart != -1L) { + L.v { "Consuming url load" } + return false // still in progress; do not bother with load + } + coreView.transition(animate) + return true + } + + private fun View.transition(animate: Boolean) { + L.v { "Registered transition" } + transitionStart = 0L // Marker for pending transition + scope.launchMain { + refreshFlow + .distinctUntilChanged() + // Pseudo windowed mode + .runningFold(false to false) { (_, prev), curr -> prev to curr } + // Take until prev was loading and current is not loading + // Unlike takeWhile, we include the last state (first non matching) + .transformWhile { + emit(it) + it != (true to false) } - - @Volatile - override var swipeAllowedByPage: Boolean = true - set(value) { - field = value - updateSwipeEnabler() - } - - private fun updateSwipeEnabler() { - val swipeEnabled = swipeAllowedByPage && !swipeDisabledByAction - if (refresh.isEnabled == swipeEnabled) return - refresh.post { refresh.isEnabled = swipeEnabled } - } - - /** - * Sets up everything - * Called by [bind] - */ - protected fun init() { - inflate(context, layoutRes, this) - reloadThemeSelf() - } - - override fun bind(container: FrostContentContainer) { - baseUrl = container.baseUrl - baseEnum = container.baseEnum - init() - scope = container - core.bind(this, container) - refresh.setOnRefreshListener { - core.reload(true) - } - - refreshFlow.distinctUntilChanged().onEach { r -> - L.v { "Refreshing $r" } - refresh.isRefreshing = r - }.launchIn(scope) - - progressFlow.onEach { p -> - progress.invisibleIf(p == 100) - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) - progress.setProgress(p, true) - else - progress.progress = p - }.launchIn(scope) - } - - override fun reloadTheme() { - reloadThemeSelf() - core.reloadTheme() - } - - override fun reloadTextSize() { - core.reloadTextSize() - } - - override fun reloadThemeSelf() { - progress.tint(themeProvider.textColor.withAlpha(180)) - refresh.setColorSchemeColors(themeProvider.iconColor) - refresh.setProgressBackgroundColorSchemeColor(themeProvider.headerColor.withAlpha(255)) - } - - override fun reloadTextSizeSelf() { - // intentionally blank - } - - override fun destroy() { - core.destroy() - } - - private var transitionStart: Long = -1 - - /** - * Hook onto the refresh observable for one cycle - * Animate toggles between the fancy ripple and the basic fade - * The cycle only starts on the first load since there may have been another process when this is registered - */ - override fun registerTransition(urlChanged: Boolean, animate: Boolean): Boolean { - if (!urlChanged && transitionStart != -1L) { - L.v { "Consuming url load" } - return false // still in progress; do not bother with load - } - coreView.transition(animate) - return true - } - - private fun View.transition(animate: Boolean) { - L.v { "Registered transition" } - transitionStart = 0L // Marker for pending transition - scope.launchMain { - refreshFlow.distinctUntilChanged() - // Pseudo windowed mode - .runningFold(false to false) { (_, prev), curr -> prev to curr } - // Take until prev was loading and current is not loading - // Unlike takeWhile, we include the last state (first non matching) - .transformWhile { emit(it); it != (true to false) } - .onEach { (prev, curr) -> - if (curr) { - transitionStart = System.currentTimeMillis() - clearAnimation() - if (isVisible) - fadeOut(duration = 200L) - } else if (prev) { // prev && !curr - if (animate && prefs.animate) circularReveal(offset = WEB_LOAD_DELAY) - else fadeIn(duration = 200L, offset = WEB_LOAD_DELAY) - L.v { "Transition loaded in ${System.currentTimeMillis() - transitionStart} ms" } - } - }.collect() - transitionStart = -1L + .onEach { (prev, curr) -> + if (curr) { + transitionStart = System.currentTimeMillis() + clearAnimation() + if (isVisible) fadeOut(duration = 200L) + } else if (prev) { // prev && !curr + if (animate && prefs.animate) circularReveal(offset = WEB_LOAD_DELAY) + else fadeIn(duration = 200L, offset = WEB_LOAD_DELAY) + L.v { "Transition loaded in ${System.currentTimeMillis() - transitionStart} ms" } + } } + .collect() + transitionStart = -1L } + } } diff --git a/app/src/main/kotlin/com/pitchedapps/frost/views/FrostRecyclerView.kt b/app/src/main/kotlin/com/pitchedapps/frost/views/FrostRecyclerView.kt index 04ee7f3c6..459f381b8 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/views/FrostRecyclerView.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/views/FrostRecyclerView.kt @@ -29,104 +29,97 @@ import com.pitchedapps.frost.contracts.FrostContentParent import com.pitchedapps.frost.fragments.RecyclerContentContract import com.pitchedapps.frost.prefs.Prefs import dagger.hilt.android.AndroidEntryPoint -import kotlinx.coroutines.launch import javax.inject.Inject +import kotlinx.coroutines.launch -/** - * Created by Allan Wang on 2017-05-29. - * - */ +/** Created by Allan Wang on 2017-05-29. */ @AndroidEntryPoint -class FrostRecyclerView @JvmOverloads constructor( - context: Context, - attrs: AttributeSet? = null, - defStyleAttr: Int = 0 -) : RecyclerView(context, attrs, defStyleAttr), FrostContentCore { +class FrostRecyclerView +@JvmOverloads +constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0) : + RecyclerView(context, attrs, defStyleAttr), FrostContentCore { - @Inject - lateinit var prefs: Prefs + @Inject lateinit var prefs: Prefs - override fun reload(animate: Boolean) = reloadBase(animate) + override fun reload(animate: Boolean) = reloadBase(animate) - override lateinit var parent: FrostContentParent + override lateinit var parent: FrostContentParent - override val currentUrl: String - get() = parent.baseUrl + override val currentUrl: String + get() = parent.baseUrl - lateinit var recyclerContract: RecyclerContentContract + lateinit var recyclerContract: RecyclerContentContract - init { - layoutManager = LinearLayoutManager(context) + init { + layoutManager = LinearLayoutManager(context) + } + + override fun bind(parent: FrostContentParent, container: FrostContentContainer): View { + this.parent = parent + if (container !is RecyclerContentContract) + throw IllegalStateException( + "FrostRecyclerView must bind to a container that is a RecyclerContentContract" + ) + this.recyclerContract = container + container.bind(this) + return this + } + + init { + isNestedScrollingEnabled = true + } + + var onReloadClear: () -> Unit = {} + + override fun reloadBase(animate: Boolean) { + if (prefs.animate) fadeOut(onFinish = onReloadClear) + scope.launch { + parent.refreshEmit(true) + recyclerContract.reload { parent.progressEmit(it) } + parent.progressEmit(100) + parent.refreshEmit(false) + if (prefs.animate) circularReveal() } + } - override fun bind(parent: FrostContentParent, container: FrostContentContainer): View { - this.parent = parent - if (container !is RecyclerContentContract) - throw IllegalStateException("FrostRecyclerView must bind to a container that is a RecyclerContentContract") - this.recyclerContract = container - container.bind(this) - return this - } + override fun clearHistory() { + // intentionally blank + } - init { - isNestedScrollingEnabled = true - } + override fun destroy() { + // todo see if any + } - var onReloadClear: () -> Unit = {} + override fun onBackPressed() = false - override fun reloadBase(animate: Boolean) { - if (prefs.animate) fadeOut(onFinish = onReloadClear) - scope.launch { - parent.refreshEmit(true) - recyclerContract.reload { parent.progressEmit(it) } - parent.progressEmit(100) - parent.refreshEmit(false) - if (prefs.animate) circularReveal() - } - } + /** If recycler is already at the top, refresh Otherwise scroll to top */ + override fun onTabClicked() { + val firstPosition = + (layoutManager as LinearLayoutManager).findFirstCompletelyVisibleItemPosition() + if (firstPosition == 0) reloadBase(true) else scrollToTop() + } - override fun clearHistory() { - // intentionally blank - } + private fun scrollToTop() { + stopScroll() + smoothScrollToPosition(0) + } - override fun destroy() { - // todo see if any - } + // nothing running in background; no need to listen + override var active: Boolean = true - override fun onBackPressed() = false + override fun reloadTheme() { + reloadThemeSelf() + } - /** - * If recycler is already at the top, refresh - * Otherwise scroll to top - */ - override fun onTabClicked() { - val firstPosition = - (layoutManager as LinearLayoutManager).findFirstCompletelyVisibleItemPosition() - if (firstPosition == 0) reloadBase(true) - else scrollToTop() - } + override fun reloadThemeSelf() { + reload(false) // todo see if there's a better solution + } - private fun scrollToTop() { - stopScroll() - smoothScrollToPosition(0) - } + override fun reloadTextSize() { + reloadTextSizeSelf() + } - // nothing running in background; no need to listen - override var active: Boolean = true - - override fun reloadTheme() { - reloadThemeSelf() - } - - override fun reloadThemeSelf() { - reload(false) // todo see if there's a better solution - } - - override fun reloadTextSize() { - reloadTextSizeSelf() - } - - override fun reloadTextSizeSelf() { - // todo - } + override fun reloadTextSizeSelf() { + // todo + } } diff --git a/app/src/main/kotlin/com/pitchedapps/frost/views/FrostVideoView.kt b/app/src/main/kotlin/com/pitchedapps/frost/views/FrostVideoView.kt index 30f8c5f7d..80d523efe 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/views/FrostVideoView.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/views/FrostVideoView.kt @@ -42,309 +42,289 @@ import kotlin.math.min /** * Created by Allan Wang on 2017-10-13. * - * VideoView with scalability - * Parent must have layout with both height & width as match_parent + * VideoView with scalability Parent must have layout with both height & width as match_parent */ -class FrostVideoView @JvmOverloads constructor( - context: Context, - attrs: AttributeSet? = null, - defStyleAttr: Int = 0 -) : VideoView(context, attrs, defStyleAttr) { +class FrostVideoView +@JvmOverloads +constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0) : + VideoView(context, attrs, defStyleAttr) { + + /** Shortcut for actual video view */ + private inline val v + get() = videoViewImpl + + var onFinishedListener: () -> Unit = {} + private lateinit var viewerContract: FrostVideoViewerContract + lateinit var containerContract: FrostVideoContainerContract + var repeat: Boolean = false + + private val videoDimensions = PointF(0f, 0f) + + companion object { /** - * Shortcut for actual video view + * Padding between minimized video and the parent borders Note that this is double the actual + * padding as we are calculating then dividing by 2 */ - private inline val v - get() = videoViewImpl + private val MINIMIZED_PADDING = 10.dpToPx + private val SWIPE_TO_CLOSE_HORIZONTAL_THRESHOLD = 2f.dpToPx + private val SWIPE_TO_CLOSE_VERTICAL_THRESHOLD = 5f.dpToPx + private val SWIPE_TO_CLOSE_OFFSET_THRESHOLD = 75f.dpToPx + const val ANIMATION_DURATION = 200L + private const val FAST_ANIMATION_DURATION = 100L + } - var onFinishedListener: () -> Unit = {} - private lateinit var viewerContract: FrostVideoViewerContract - lateinit var containerContract: FrostVideoContainerContract - var repeat: Boolean = false + private var videoBounds = RectF() - private val videoDimensions = PointF(0f, 0f) - - companion object { - - /** - * Padding between minimized video and the parent borders - * Note that this is double the actual padding - * as we are calculating then dividing by 2 - */ - private val MINIMIZED_PADDING = 10.dpToPx - private val SWIPE_TO_CLOSE_HORIZONTAL_THRESHOLD = 2f.dpToPx - private val SWIPE_TO_CLOSE_VERTICAL_THRESHOLD = 5f.dpToPx - private val SWIPE_TO_CLOSE_OFFSET_THRESHOLD = 75f.dpToPx - const val ANIMATION_DURATION = 200L - private const val FAST_ANIMATION_DURATION = 100L + var isExpanded: Boolean = true + set(value) { + if (field == value) return + field = value + val origX = translationX + val origY = translationY + val origScale = scaleX + if (field) { + ProgressAnimator.ofFloat { + duration = ANIMATION_DURATION + interpolator = AnimHolder.fastOutSlowInInterpolator(context) + withAnimator { viewerContract.onExpand(it) } + withAnimator(origScale, 1f) { scaleXY = it } + withAnimator(origX, 0f) { translationX = it } + withAnimator(origY, 0f) { translationY = it } + withEndAction { if (!isPlaying) showControls() else viewerContract.onControlsHidden() } + } + .start() + } else { + hideControls() + val (scale, tX, tY) = mapBounds() + ProgressAnimator.ofFloat { + duration = ANIMATION_DURATION + interpolator = AnimHolder.fastOutSlowInInterpolator(context) + withAnimator { viewerContract.onExpand(1f - it) } + withAnimator(origScale, scale) { scaleXY = it } + withAnimator(origX, tX) { translationX = it } + withAnimator(origY, tY) { translationY = it } + } + .start() + } } - private var videoBounds = RectF() + /** + * Store the boundaries of the minimized video, and return the necessary transitions to get there + */ + private fun mapBounds(): Triple { + if (videoDimensions.x <= 0f || videoDimensions.y <= 0f) { + L.d { "Attempted to toggle video expansion when points have not been finalized" } + val dimen = min(height, width).toFloat() + videoDimensions.set(dimen, dimen) + } + val portrait = height > width + val scale = + min( + height / (if (portrait) 4f else 2.3f) / videoDimensions.y, + width / (if (portrait) 2.3f else 4f) / videoDimensions.x + ) + val desiredHeight = scale * videoDimensions.y + val desiredWidth = scale * videoDimensions.x + val padding = containerContract.lowerVideoPadding + val offsetX = width - MINIMIZED_PADDING - desiredWidth + val offsetY = height - MINIMIZED_PADDING - desiredHeight + val tX = offsetX / 2 - padding.x + val tY = offsetY / 2 - padding.y + videoBounds.set(offsetX, offsetY, width.toFloat(), height.toFloat()) + videoBounds.offset(padding.x, padding.y) + L.v { "Video bounds: fullwidth $width, fullheight $height, scale $scale, tX $tX, tY $tY" } + return Triple(scale, tX, tY) + } - var isExpanded: Boolean = true - set(value) { - if (field == value) return - field = value - val origX = translationX - val origY = translationY - val origScale = scaleX - if (field) { - ProgressAnimator.ofFloat { - duration = ANIMATION_DURATION - interpolator = AnimHolder.fastOutSlowInInterpolator(context) - withAnimator { viewerContract.onExpand(it) } - withAnimator(origScale, 1f) { scaleXY = it } - withAnimator(origX, 0f) { translationX = it } - withAnimator(origY, 0f) { translationY = it } - withEndAction { - if (!isPlaying) showControls() - else viewerContract.onControlsHidden() - } - }.start() - } else { - hideControls() - val (scale, tX, tY) = mapBounds() - ProgressAnimator.ofFloat { - duration = ANIMATION_DURATION - interpolator = AnimHolder.fastOutSlowInInterpolator(context) - withAnimator { viewerContract.onExpand(1f - it) } - withAnimator(origScale, scale) { scaleXY = it } - withAnimator(origX, tX) { translationX = it } - withAnimator(origY, tY) { translationY = it } - }.start() + fun updateLocation() { + L.d { "Update video location" } + val (scale, tX, tY) = if (isExpanded) Triple(1f, 0f, 0f) else mapBounds() + scaleXY = scale + translationX = tX + translationY = tY + } + + init { + setOnPreparedListener { + start() + if (isExpanded) showControls() + } + setOnErrorListener { + L.e(it) { "Failed to load video ${videoUri?.toString()?.formattedFbUrl}" } + toast(R.string.video_load_failed, Toast.LENGTH_SHORT) + destroy() + true + } + setOnCompletionListener { if (repeat) restart() else viewerContract.onVideoComplete() } + setOnTouchListener(FrameTouchListener(context)) + v.setOnTouchListener(VideoTouchListener(context)) + setOnVideoSizedChangedListener { intrinsicWidth, intrinsicHeight, pixelWidthHeightRatio -> + // todo use provided ratio? + val ratio = + min(width.toFloat() / intrinsicWidth, height.toFloat() / intrinsicHeight.toFloat()) + + /** Only remap if not expanded and if dimensions have changed */ + val shouldRemap = + !isExpanded && + (videoDimensions.x != ratio * intrinsicWidth || + videoDimensions.y != ratio * intrinsicHeight) + videoDimensions.set(ratio * intrinsicWidth, ratio * intrinsicHeight) + if (shouldRemap) updateLocation() + } + } + + fun setViewerContract(contract: FrostVideoViewerContract) { + this.viewerContract = contract + (videoControls as? VideoControls)?.setVisibilityListener(viewerContract) + } + + fun jumpToStart() { + pause() + v.seekTo(0) + videoControls?.finishLoading() + } + + override fun pause() { + audioFocusHelper.abandonFocus() + videoViewImpl.pause() + keepScreenOn = false + if (isExpanded) videoControls?.updatePlaybackState(false) + } + + override fun restart(): Boolean { + videoUri ?: return false + if (videoViewImpl.restart() && isExpanded && !repeat) { + videoControls?.showLoading(true) + return true + } + return false + } + + private fun hideControls() { + if (videoControls?.isVisible == true) videoControls?.hide(false) + } + + private fun toggleControls() { + if (videoControls?.isVisible == true) hideControls() else showControls() + } + + fun shouldParentAcceptTouch(ev: MotionEvent): Boolean { + if (isExpanded) return true + return !videoBounds.contains(ev.x, ev.y) + } + + fun destroy() { + stopPlayback() + if (alpha > 0f) + ProgressAnimator.ofFloat { + duration = FAST_ANIMATION_DURATION + withAnimator(alpha, 0f) { alpha = it } + withEndAction { onFinishedListener() } + } + .start() + else onFinishedListener() + } + + private fun onHorizontalSwipe(offset: Float) { + val alpha = max((1f - abs(offset / SWIPE_TO_CLOSE_OFFSET_THRESHOLD)) * 0.5f + 0.5f, 0f) + this.alpha = alpha + } + + /* + * ------------------------------------------------------------------- + * Touch Listeners + * ------------------------------------------------------------------- + */ + + private inner class FrameTouchListener(context: Context) : + GestureDetector.SimpleOnGestureListener(), View.OnTouchListener { + + private val gestureDetector: GestureDetector = GestureDetector(context, this) + + @SuppressLint("ClickableViewAccessibility") + override fun onTouch(view: View, event: MotionEvent): Boolean { + if (!isExpanded) return false + gestureDetector.onTouchEvent(event) + return true + } + + override fun onSingleTapConfirmed(event: MotionEvent): Boolean { + if (!viewerContract.onSingleTapConfirmed(event)) toggleControls() + return true + } + + override fun onDoubleTap(e: MotionEvent): Boolean { + isExpanded = !isExpanded + return true + } + } + + /** + * Monitors the view click events to show and hide the video controls if they have been specified. + */ + private inner class VideoTouchListener(context: Context) : + GestureDetector.SimpleOnGestureListener(), View.OnTouchListener { + + private val gestureDetector: GestureDetector = GestureDetector(context, this) + private val downLoc = PointF() + private var baseSwipeX = -1f + private var baseTranslateX = -1f + private var checkForDismiss = true + private var onSwipe = false + + @SuppressLint("ClickableViewAccessibility") + override fun onTouch(view: View, event: MotionEvent): Boolean { + gestureDetector.onTouchEvent(event) + when (event.actionMasked) { + MotionEvent.ACTION_DOWN -> { + checkForDismiss = !isExpanded + onSwipe = false + downLoc.x = event.rawX + downLoc.y = event.rawY + } + MotionEvent.ACTION_MOVE -> { + if (onSwipe) { + val dx = baseSwipeX - event.rawX + translationX = baseTranslateX - dx + onHorizontalSwipe(dx) + } else if (checkForDismiss) { + if (abs(event.rawY - downLoc.y) > SWIPE_TO_CLOSE_VERTICAL_THRESHOLD) + checkForDismiss = false + else if (abs(event.rawX - downLoc.x) > SWIPE_TO_CLOSE_HORIZONTAL_THRESHOLD) { + onSwipe = true + baseSwipeX = event.rawX + baseTranslateX = translationX } + } } - - /** - * Store the boundaries of the minimized video, - * and return the necessary transitions to get there - */ - private fun mapBounds(): Triple { - if (videoDimensions.x <= 0f || videoDimensions.y <= 0f) { - L.d { "Attempted to toggle video expansion when points have not been finalized" } - val dimen = min(height, width).toFloat() - videoDimensions.set(dimen, dimen) + MotionEvent.ACTION_UP -> { + if (onSwipe) { + if (abs(baseSwipeX - event.rawX) > SWIPE_TO_CLOSE_OFFSET_THRESHOLD) destroy() + else + animate() + .translationX(baseTranslateX) + .setDuration(FAST_ANIMATION_DURATION) + .withStartAction { animate().alpha(1f) } + } } - val portrait = height > width - val scale = min( - height / (if (portrait) 4f else 2.3f) / videoDimensions.y, - width / (if (portrait) 2.3f else 4f) / videoDimensions.x - ) - val desiredHeight = scale * videoDimensions.y - val desiredWidth = scale * videoDimensions.x - val padding = containerContract.lowerVideoPadding - val offsetX = width - MINIMIZED_PADDING - desiredWidth - val offsetY = height - MINIMIZED_PADDING - desiredHeight - val tX = offsetX / 2 - padding.x - val tY = offsetY / 2 - padding.y - videoBounds.set(offsetX, offsetY, width.toFloat(), height.toFloat()) - videoBounds.offset(padding.x, padding.y) - L.v { "Video bounds: fullwidth $width, fullheight $height, scale $scale, tX $tX, tY $tY" } - return Triple(scale, tX, tY) + } + return true } - fun updateLocation() { - L.d { "Update video location" } - val (scale, tX, tY) = if (isExpanded) Triple(1f, 0f, 0f) else mapBounds() - scaleXY = scale - translationX = tX - translationY = tY + override fun onSingleTapConfirmed(event: MotionEvent): Boolean { + if (viewerContract.onSingleTapConfirmed(event)) return true + if (!isExpanded) { + isExpanded = true + return true + } + toggleControls() + return true } - init { - setOnPreparedListener { - start() - if (isExpanded) showControls() - } - setOnErrorListener { - L.e(it) { "Failed to load video ${videoUri?.toString()?.formattedFbUrl}" } - toast(R.string.video_load_failed, Toast.LENGTH_SHORT) - destroy() - true - } - setOnCompletionListener { - if (repeat) restart() - else viewerContract.onVideoComplete() - } - setOnTouchListener(FrameTouchListener(context)) - v.setOnTouchListener(VideoTouchListener(context)) - setOnVideoSizedChangedListener { intrinsicWidth, intrinsicHeight, pixelWidthHeightRatio -> - // todo use provided ratio? - val ratio = - min(width.toFloat() / intrinsicWidth, height.toFloat() / intrinsicHeight.toFloat()) - - /** - * Only remap if not expanded and if dimensions have changed - */ - val shouldRemap = !isExpanded && - (videoDimensions.x != ratio * intrinsicWidth || videoDimensions.y != ratio * intrinsicHeight) - videoDimensions.set(ratio * intrinsicWidth, ratio * intrinsicHeight) - if (shouldRemap) updateLocation() - } - } - - fun setViewerContract(contract: FrostVideoViewerContract) { - this.viewerContract = contract - (videoControls as? VideoControls)?.setVisibilityListener(viewerContract) - } - - fun jumpToStart() { - pause() - v.seekTo(0) - videoControls?.finishLoading() - } - - override fun pause() { - audioFocusHelper.abandonFocus() - videoViewImpl.pause() - keepScreenOn = false - if (isExpanded) - videoControls?.updatePlaybackState(false) - } - - override fun restart(): Boolean { - videoUri ?: return false - if (videoViewImpl.restart() && isExpanded && !repeat) { - videoControls?.showLoading(true) - return true - } - return false - } - - private fun hideControls() { - if (videoControls?.isVisible == true) - videoControls?.hide(false) - } - - private fun toggleControls() { - if (videoControls?.isVisible == true) - hideControls() - else - showControls() - } - - fun shouldParentAcceptTouch(ev: MotionEvent): Boolean { - if (isExpanded) return true - return !videoBounds.contains(ev.x, ev.y) - } - - fun destroy() { - stopPlayback() - if (alpha > 0f) - ProgressAnimator.ofFloat { - duration = FAST_ANIMATION_DURATION - withAnimator(alpha, 0f) { alpha = it } - withEndAction { onFinishedListener() } - }.start() - else - onFinishedListener() - } - - private fun onHorizontalSwipe(offset: Float) { - val alpha = - max((1f - abs(offset / SWIPE_TO_CLOSE_OFFSET_THRESHOLD)) * 0.5f + 0.5f, 0f) - this.alpha = alpha - } - - /* - * ------------------------------------------------------------------- - * Touch Listeners - * ------------------------------------------------------------------- - */ - - private inner class FrameTouchListener(context: Context) : - GestureDetector.SimpleOnGestureListener(), - View.OnTouchListener { - - private val gestureDetector: GestureDetector = GestureDetector(context, this) - - @SuppressLint("ClickableViewAccessibility") - override fun onTouch(view: View, event: MotionEvent): Boolean { - if (!isExpanded) return false - gestureDetector.onTouchEvent(event) - return true - } - - override fun onSingleTapConfirmed(event: MotionEvent): Boolean { - if (!viewerContract.onSingleTapConfirmed(event)) - toggleControls() - return true - } - - override fun onDoubleTap(e: MotionEvent): Boolean { - isExpanded = !isExpanded - return true - } - } - - /** - * Monitors the view click events to show and hide the video controls if they have been specified. - */ - private inner class VideoTouchListener(context: Context) : - GestureDetector.SimpleOnGestureListener(), - View.OnTouchListener { - - private val gestureDetector: GestureDetector = GestureDetector(context, this) - private val downLoc = PointF() - private var baseSwipeX = -1f - private var baseTranslateX = -1f - private var checkForDismiss = true - private var onSwipe = false - - @SuppressLint("ClickableViewAccessibility") - override fun onTouch(view: View, event: MotionEvent): Boolean { - gestureDetector.onTouchEvent(event) - when (event.actionMasked) { - MotionEvent.ACTION_DOWN -> { - checkForDismiss = !isExpanded - onSwipe = false - downLoc.x = event.rawX - downLoc.y = event.rawY - } - MotionEvent.ACTION_MOVE -> { - if (onSwipe) { - val dx = baseSwipeX - event.rawX - translationX = baseTranslateX - dx - onHorizontalSwipe(dx) - } else if (checkForDismiss) { - if (abs(event.rawY - downLoc.y) > SWIPE_TO_CLOSE_VERTICAL_THRESHOLD) - checkForDismiss = false - else if (abs(event.rawX - downLoc.x) > SWIPE_TO_CLOSE_HORIZONTAL_THRESHOLD) { - onSwipe = true - baseSwipeX = event.rawX - baseTranslateX = translationX - } - } - } - MotionEvent.ACTION_UP -> { - if (onSwipe) { - if (abs(baseSwipeX - event.rawX) > SWIPE_TO_CLOSE_OFFSET_THRESHOLD) - destroy() - else - animate().translationX(baseTranslateX).setDuration( - FAST_ANIMATION_DURATION - ).withStartAction { - animate().alpha(1f) - } - } - } - } - return true - } - - override fun onSingleTapConfirmed(event: MotionEvent): Boolean { - if (viewerContract.onSingleTapConfirmed(event)) return true - if (!isExpanded) { - isExpanded = true - return true - } - toggleControls() - return true - } - - override fun onDoubleTap(e: MotionEvent): Boolean { - isExpanded = !isExpanded - return true - } + override fun onDoubleTap(e: MotionEvent): Boolean { + isExpanded = !isExpanded + return true } + } } diff --git a/app/src/main/kotlin/com/pitchedapps/frost/views/FrostVideoViewer.kt b/app/src/main/kotlin/com/pitchedapps/frost/views/FrostVideoViewer.kt index 836d8666b..4a2ea7ce1 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/views/FrostVideoViewer.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/views/FrostVideoViewer.kt @@ -51,206 +51,185 @@ import com.pitchedapps.frost.utils.frostDownload import dagger.hilt.android.AndroidEntryPoint import javax.inject.Inject -/** - * Created by Allan Wang on 2017-10-13. - */ +/** Created by Allan Wang on 2017-10-13. */ @AndroidEntryPoint -class FrostVideoViewer @JvmOverloads constructor( - context: Context, - attrs: AttributeSet? = null, - defStyleAttr: Int = 0 -) : FrameLayout(context, attrs, defStyleAttr), FrostVideoViewerContract { +class FrostVideoViewer +@JvmOverloads +constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0) : + FrameLayout(context, attrs, defStyleAttr), FrostVideoViewerContract { - companion object { - /** - * Matches VideoControls.CONTROL_VISIBILITY_ANIMATION_LENGTH - */ - private const val CONTROL_ANIMATION_DURATION = 300L - - /** - * Simplified binding to add video to layout, and remove it when finished - * This is under the assumption that the container allows for overlays, - * such as a FrameLayout - */ - fun showVideo( - url: String, - repeat: Boolean, - contract: FrostVideoContainerContract - ): FrostVideoViewer { - val container = contract.videoContainer - val videoViewer = FrostVideoViewer(container.context) - container.addView(videoViewer) - videoViewer.bringToFront() - videoViewer.setVideo(url, repeat) - videoViewer.binding.video.containerContract = contract - videoViewer.binding.video.onFinishedListener = - { container.removeView(videoViewer); contract.onVideoFinished() } - return videoViewer - } - } - - @Inject - lateinit var prefs: Prefs - - @Inject - lateinit var themeProvider: ThemeProvider - - @Inject - lateinit var cookieDao: CookieDao - - private val binding: ViewVideoBinding = - ViewVideoBinding.inflate(LayoutInflater.from(context), this, true) - - init { - binding.init() - } - - fun ViewVideoBinding.init() { - alpha = 0f - videoBackground.setBackgroundColor( - if (!prefs.blackMediaBg && themeProvider.bgColor.isColorDark) - themeProvider.bgColor.withMinAlpha(200) - else - Color.BLACK - ) - video.setViewerContract(this@FrostVideoViewer) - video.pause() - videoToolbar.inflateMenu(R.menu.menu_video) - context.setMenuIcons( - videoToolbar.menu, themeProvider.iconColor, - R.id.action_pip to GoogleMaterial.Icon.gmd_picture_in_picture_alt, - R.id.action_download to GoogleMaterial.Icon.gmd_file_download - ) - videoToolbar.setOnMenuItemClickListener { - when (it.itemId) { - R.id.action_pip -> video.isExpanded = false - R.id.action_download -> context.ctxCoroutine.launchMain { - val cookie = cookieDao.currentCookie(prefs) ?: return@launchMain - context.frostDownload(cookie.cookie, video.videoUri) - } - } - true - } - videoRestart.gone().setIcon(GoogleMaterial.Icon.gmd_replay, 64) - videoRestart.setOnClickListener { - video.restart() - videoRestart.fadeOut { videoRestart.gone() } - } - } - - fun setVideo(url: String, repeat: Boolean = false) { - with(binding) { - L.d { "Load video; repeat: $repeat" } - L._d { "Video Url: $url" } - animate().alpha(1f).setDuration(FrostVideoView.ANIMATION_DURATION).start() - video.setVideoURI(Uri.parse(url)) - video.repeat = repeat - } - } + companion object { + /** Matches VideoControls.CONTROL_VISIBILITY_ANIMATION_LENGTH */ + private const val CONTROL_ANIMATION_DURATION = 300L /** - * Handle back presses - * returns true if consumed, false otherwise + * Simplified binding to add video to layout, and remove it when finished This is under the + * assumption that the container allows for overlays, such as a FrameLayout */ - fun onBackPressed(): Boolean { - with(binding) { - parent ?: return false - if (video.isExpanded) - video.isExpanded = false - else - video.destroy() - return true - } + fun showVideo( + url: String, + repeat: Boolean, + contract: FrostVideoContainerContract + ): FrostVideoViewer { + val container = contract.videoContainer + val videoViewer = FrostVideoViewer(container.context) + container.addView(videoViewer) + videoViewer.bringToFront() + videoViewer.setVideo(url, repeat) + videoViewer.binding.video.containerContract = contract + videoViewer.binding.video.onFinishedListener = { + container.removeView(videoViewer) + contract.onVideoFinished() + } + return videoViewer } + } - fun pause() = binding.video.pause() + @Inject lateinit var prefs: Prefs - /* - * ------------------------------------------------------------- - * FrostVideoViewerContract - * ------------------------------------------------------------- - */ + @Inject lateinit var themeProvider: ThemeProvider - override fun onExpand(progress: Float) { - with(binding) { - videoToolbar.goneIf(progress == 0f).alpha = progress - videoBackground.alpha = progress - } + @Inject lateinit var cookieDao: CookieDao + + private val binding: ViewVideoBinding = + ViewVideoBinding.inflate(LayoutInflater.from(context), this, true) + + init { + binding.init() + } + + fun ViewVideoBinding.init() { + alpha = 0f + videoBackground.setBackgroundColor( + if (!prefs.blackMediaBg && themeProvider.bgColor.isColorDark) + themeProvider.bgColor.withMinAlpha(200) + else Color.BLACK + ) + video.setViewerContract(this@FrostVideoViewer) + video.pause() + videoToolbar.inflateMenu(R.menu.menu_video) + context.setMenuIcons( + videoToolbar.menu, + themeProvider.iconColor, + R.id.action_pip to GoogleMaterial.Icon.gmd_picture_in_picture_alt, + R.id.action_download to GoogleMaterial.Icon.gmd_file_download + ) + videoToolbar.setOnMenuItemClickListener { + when (it.itemId) { + R.id.action_pip -> video.isExpanded = false + R.id.action_download -> + context.ctxCoroutine.launchMain { + val cookie = cookieDao.currentCookie(prefs) ?: return@launchMain + context.frostDownload(cookie.cookie, video.videoUri) + } + } + true } - - override fun onSingleTapConfirmed(event: MotionEvent): Boolean { - with(binding) { - if (videoRestart.isVisible) { - videoRestart.performClick() - return true - } - return false - } + videoRestart.gone().setIcon(GoogleMaterial.Icon.gmd_replay, 64) + videoRestart.setOnClickListener { + video.restart() + videoRestart.fadeOut { videoRestart.gone() } } + } - override fun onVideoComplete() { - with(binding) { - video.jumpToStart() - videoRestart.fadeIn() - } + fun setVideo(url: String, repeat: Boolean = false) { + with(binding) { + L.d { "Load video; repeat: $repeat" } + L._d { "Video Url: $url" } + animate().alpha(1f).setDuration(FrostVideoView.ANIMATION_DURATION).start() + video.setVideoURI(Uri.parse(url)) + video.repeat = repeat } + } - fun updateLocation() { - with(binding) { - viewTreeObserver.addOnGlobalLayoutListener(object : - ViewTreeObserver.OnGlobalLayoutListener { - override fun onGlobalLayout() { - video.updateLocation() - viewTreeObserver.removeOnGlobalLayoutListener(this) - } - }) - } + /** Handle back presses returns true if consumed, false otherwise */ + fun onBackPressed(): Boolean { + with(binding) { + parent ?: return false + if (video.isExpanded) video.isExpanded = false else video.destroy() + return true } + } - override fun onControlsShown() { - with(binding) { - if (video.isExpanded) - videoToolbar.fadeIn( - duration = CONTROL_ANIMATION_DURATION, - onStart = { videoToolbar.visible() } - ) - } - } + fun pause() = binding.video.pause() - override fun onControlsHidden() { - with(binding) { - if (!videoToolbar.isGone) - videoToolbar.fadeOut(duration = CONTROL_ANIMATION_DURATION) { videoToolbar.gone() } - } + /* + * ------------------------------------------------------------- + * FrostVideoViewerContract + * ------------------------------------------------------------- + */ + + override fun onExpand(progress: Float) { + with(binding) { + videoToolbar.goneIf(progress == 0f).alpha = progress + videoBackground.alpha = progress } + } + + override fun onSingleTapConfirmed(event: MotionEvent): Boolean { + with(binding) { + if (videoRestart.isVisible) { + videoRestart.performClick() + return true + } + return false + } + } + + override fun onVideoComplete() { + with(binding) { + video.jumpToStart() + videoRestart.fadeIn() + } + } + + fun updateLocation() { + with(binding) { + viewTreeObserver.addOnGlobalLayoutListener( + object : ViewTreeObserver.OnGlobalLayoutListener { + override fun onGlobalLayout() { + video.updateLocation() + viewTreeObserver.removeOnGlobalLayoutListener(this) + } + } + ) + } + } + + override fun onControlsShown() { + with(binding) { + if (video.isExpanded) + videoToolbar.fadeIn( + duration = CONTROL_ANIMATION_DURATION, + onStart = { videoToolbar.visible() } + ) + } + } + + override fun onControlsHidden() { + with(binding) { + if (!videoToolbar.isGone) + videoToolbar.fadeOut(duration = CONTROL_ANIMATION_DURATION) { videoToolbar.gone() } + } + } } interface FrostVideoViewerContract : VideoControlsVisibilityListener { - fun onSingleTapConfirmed(event: MotionEvent): Boolean + fun onSingleTapConfirmed(event: MotionEvent): Boolean - /** - * Process of expansion - * 1f represents an expanded view, 0f represents a minimized view - */ - fun onExpand(progress: Float) + /** Process of expansion 1f represents an expanded view, 0f represents a minimized view */ + fun onExpand(progress: Float) - fun onVideoComplete() + fun onVideoComplete() } interface FrostVideoContainerContract { - /** - * Returns extra padding to be added - * from the right and from the bottom respectively - */ - val lowerVideoPadding: PointF + /** Returns extra padding to be added from the right and from the bottom respectively */ + val lowerVideoPadding: PointF - /** - * Get the container which will hold the video viewer - */ - val videoContainer: FrameLayout + /** Get the container which will hold the video viewer */ + val videoContainer: FrameLayout - /** - * Called once the video has stopped & should be removed - */ - fun onVideoFinished() + /** Called once the video has stopped & should be removed */ + fun onVideoFinished() } diff --git a/app/src/main/kotlin/com/pitchedapps/frost/views/FrostViewPager.kt b/app/src/main/kotlin/com/pitchedapps/frost/views/FrostViewPager.kt index f04a2f571..12ee3b41f 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/views/FrostViewPager.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/views/FrostViewPager.kt @@ -31,28 +31,25 @@ import javax.inject.Inject * Basic override to allow us to control swiping */ @AndroidEntryPoint -class FrostViewPager @JvmOverloads constructor( - context: Context, - attrs: AttributeSet? = null -) : ViewPager(context, attrs) { +class FrostViewPager @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null) : + ViewPager(context, attrs) { - @Inject - lateinit var prefs: Prefs + @Inject lateinit var prefs: Prefs - var enableSwipe = true + var enableSwipe = true - override fun onInterceptTouchEvent(ev: MotionEvent?) = - try { - prefs.viewpagerSwipe && enableSwipe && super.onInterceptTouchEvent(ev) - } catch (e: IllegalArgumentException) { - false - } + override fun onInterceptTouchEvent(ev: MotionEvent?) = + try { + prefs.viewpagerSwipe && enableSwipe && super.onInterceptTouchEvent(ev) + } catch (e: IllegalArgumentException) { + false + } - @SuppressLint("ClickableViewAccessibility") - override fun onTouchEvent(ev: MotionEvent?): Boolean = - try { - prefs.viewpagerSwipe && enableSwipe && super.onTouchEvent(ev) - } catch (e: IllegalArgumentException) { - false - } + @SuppressLint("ClickableViewAccessibility") + override fun onTouchEvent(ev: MotionEvent?): Boolean = + try { + prefs.viewpagerSwipe && enableSwipe && super.onTouchEvent(ev) + } catch (e: IllegalArgumentException) { + false + } } diff --git a/app/src/main/kotlin/com/pitchedapps/frost/views/FrostWebView.kt b/app/src/main/kotlin/com/pitchedapps/frost/views/FrostWebView.kt index 140b49014..a0406a82f 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/views/FrostWebView.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/views/FrostWebView.kt @@ -56,202 +56,182 @@ import kotlin.math.abs import kotlin.math.max import kotlin.math.min -/** - * Created by Allan Wang on 2017-05-29. - * - */ +/** Created by Allan Wang on 2017-05-29. */ @AndroidEntryPoint -class FrostWebView @JvmOverloads constructor( - context: Context, - attrs: AttributeSet? = null, - defStyleAttr: Int = 0 -) : NestedWebView(context, attrs, defStyleAttr), FrostContentCore { +class FrostWebView +@JvmOverloads +constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0) : + NestedWebView(context, attrs, defStyleAttr), FrostContentCore { - @Inject - lateinit var activity: Activity + @Inject lateinit var activity: Activity - @Inject - lateinit var fbCookie: FbCookie + @Inject lateinit var fbCookie: FbCookie - @Inject - lateinit var prefs: Prefs + @Inject lateinit var prefs: Prefs - @Inject - lateinit var themeProvider: ThemeProvider + @Inject lateinit var themeProvider: ThemeProvider - @Inject - lateinit var webFileChooser: WebFileChooser + @Inject lateinit var webFileChooser: WebFileChooser - @Inject - lateinit var cookieDao: CookieDao + @Inject lateinit var cookieDao: CookieDao - @Inject - lateinit var frostWebComponentBuilder: FrostWebComponentBuilder + @Inject lateinit var frostWebComponentBuilder: FrostWebComponentBuilder - override fun reload(animate: Boolean) { - if (parent.registerTransition(false, animate)) - super.reload() + override fun reload(animate: Boolean) { + if (parent.registerTransition(false, animate)) super.reload() + } + + override lateinit var parent: FrostContentParent + + internal lateinit var frostWebClient: FrostWebViewClient + + override val currentUrl: String + get() = url ?: "" + + @SuppressLint("SetJavaScriptEnabled") + override fun bind(parent: FrostContentParent, container: FrostContentContainer): View { + this.parent = parent + val component = frostWebComponentBuilder.frostParent(parent).frostWebView(this).build() + val webEntryPoint = EntryPoints.get(component, FrostWebEntryPoint::class.java) + val clientEntryPoint = EntryPoints.get(component, FrostWebClientEntryPoint::class.java) + userAgentString = USER_AGENT + with(settings) { + javaScriptEnabled = true + mediaPlaybackRequiresUserGesture = false // TODO check if we need this + allowFileAccess = true + textZoom = prefs.webTextScaling + domStorageEnabled = true + } + setLayerType(LAYER_TYPE_HARDWARE, null) + // attempt to get custom client; otherwise fallback to original + frostWebClient = + when (parent.baseEnum) { + FbItem.MESSENGER -> FrostWebViewClientMessenger(this) + FbItem.MENU -> FrostWebViewClientMenu(this) + else -> clientEntryPoint.webClient() + } + webViewClient = frostWebClient + webChromeClient = FrostChromeClient(this, themeProvider, webFileChooser) + addJavascriptInterface(webEntryPoint.frostJsi(), "Frost") + setBackgroundColor(Color.TRANSPARENT) + setDownloadListener { url, userAgent, contentDisposition, mimetype, contentLength -> + context.ctxCoroutine.launchMain { + val cookie = cookieDao.currentCookie(prefs) ?: return@launchMain + context.frostDownload( + cookie.cookie, + url, + userAgent, + contentDisposition, + mimetype, + contentLength + ) + } + } + return this + } + + /** + * Wrapper to the main userAgentString to cache it. This decouples it from the UiThread + * + * Note that this defaults to null, but the main purpose is to check if we've set our own agent. + * + * A null value may be interpreted as the default value + */ + var userAgentString: String? = null + set(value) { + field = value + settings.userAgentString = value } - override lateinit var parent: FrostContentParent + init { + isNestedScrollingEnabled = true + } - internal lateinit var frostWebClient: FrostWebViewClient + fun loadUrl(url: String?, animate: Boolean) { + if (url == null) return + if (parent.registerTransition(this.url != url, animate)) super.loadUrl(url) + } - override val currentUrl: String - get() = url ?: "" + override fun reloadBase(animate: Boolean) { + loadUrl(parent.baseUrl, animate) + } - @SuppressLint("SetJavaScriptEnabled") - override fun bind(parent: FrostContentParent, container: FrostContentContainer): View { - this.parent = parent - val component = frostWebComponentBuilder.frostParent(parent).frostWebView(this).build() - val webEntryPoint = EntryPoints.get(component, FrostWebEntryPoint::class.java) - val clientEntryPoint = EntryPoints.get(component, FrostWebClientEntryPoint::class.java) - userAgentString = USER_AGENT - with(settings) { - javaScriptEnabled = true - mediaPlaybackRequiresUserGesture = false // TODO check if we need this - allowFileAccess = true - textZoom = prefs.webTextScaling - domStorageEnabled = true + /** + * 2018-10-17. facebook automatically adds their home page to the back stack, regardless of the + * loaded url. We will make sure we skip it when going back. + * + * 2019-10-14. Looks like facebook now randomly populates some links with the home page, + * especially those that are launched with a blank target... In some cases, there can be more than + * one home target in a row. + */ + override fun onBackPressed(): Boolean { + val list = copyBackForwardList() + if (list.currentIndex >= 2) { + val skipCount = + (1..list.currentIndex).firstOrNull { + list.getItemAtIndex(list.currentIndex - it).url != FB_HOME_URL } - setLayerType(LAYER_TYPE_HARDWARE, null) - // attempt to get custom client; otherwise fallback to original - frostWebClient = when (parent.baseEnum) { - FbItem.MESSENGER -> FrostWebViewClientMessenger(this) - FbItem.MENU -> FrostWebViewClientMenu(this) - else -> clientEntryPoint.webClient() - } - webViewClient = frostWebClient - webChromeClient = FrostChromeClient(this, themeProvider, webFileChooser) - addJavascriptInterface(webEntryPoint.frostJsi(), "Frost") - setBackgroundColor(Color.TRANSPARENT) - setDownloadListener { url, userAgent, contentDisposition, mimetype, contentLength -> - context.ctxCoroutine.launchMain { - val cookie = cookieDao.currentCookie(prefs) ?: return@launchMain - context.frostDownload( - cookie.cookie, - url, - userAgent, - contentDisposition, - mimetype, - contentLength - ) - } - } - return this + ?: return false // If no non home url is found, we will treat the stack as empty + L.v { "onBackPress: going back ${if (skipCount == 1) "one page" else "$skipCount pages"}" } + goBackOrForward(-skipCount) + return true + } + if (list.currentIndex == 1 && list.getItemAtIndex(0).url == FB_HOME_URL) { + return false + } + if (list.currentIndex > 0) { + goBack() + return true + } + return false + } + + /** If webview is already at the top, refresh Otherwise scroll to top */ + override fun onTabClicked() { + if (scrollY < 5) reloadBase(true) else scrollToTop() + } + + private fun scrollToTop() { + flingScroll(0, 0) // stop fling + if (scrollY > 10000) scrollTo(0, 0) else smoothScrollTo(0) + } + + private fun smoothScrollTo(y: Int) { + ValueAnimator.ofInt(scrollY, y).apply { + duration = min(abs(scrollY - y), 500).toLong() + interpolator = AnimHolder.fastOutSlowInInterpolator(context) + addUpdateListener { scrollY = it.animatedValue as Int } + start() + } + } + + private fun smoothScrollBy(y: Int) = smoothScrollTo(max(0, scrollY + y)) + + override var active: Boolean = true + set(value) { + if (field == value) return + field = value + if (field) onResume() else onPause() } - /** - * Wrapper to the main userAgentString to cache it. - * This decouples it from the UiThread - * - * Note that this defaults to null, but the main purpose is to - * check if we've set our own agent. - * - * A null value may be interpreted as the default value - */ - var userAgentString: String? = null - set(value) { - field = value - settings.userAgentString = value - } + override fun reloadTheme() { + reloadThemeSelf() + } - init { - isNestedScrollingEnabled = true - } + override fun reloadThemeSelf() { + reload(false) // todo see if there's a better solution + } - fun loadUrl(url: String?, animate: Boolean) { - if (url == null) return - if (parent.registerTransition(this.url != url, animate)) - super.loadUrl(url) - } + override fun reloadTextSize() { + reloadTextSizeSelf() + } - override fun reloadBase(animate: Boolean) { - loadUrl(parent.baseUrl, animate) - } + override fun reloadTextSizeSelf() { + settings.textZoom = prefs.webTextScaling + } - /** - * 2018-10-17. facebook automatically adds their home page to the back stack, - * regardless of the loaded url. We will make sure we skip it when going back. - * - * 2019-10-14. Looks like facebook now randomly populates some links with the home page, - * especially those that are launched with a blank target... - * In some cases, there can be more than one home target in a row. - */ - override fun onBackPressed(): Boolean { - val list = copyBackForwardList() - if (list.currentIndex >= 2) { - val skipCount = (1..list.currentIndex).firstOrNull { - list.getItemAtIndex(list.currentIndex - it).url != FB_HOME_URL - } ?: return false // If no non home url is found, we will treat the stack as empty - L.v { "onBackPress: going back ${if (skipCount == 1) "one page" else "$skipCount pages"}" } - goBackOrForward(-skipCount) - return true - } - if (list.currentIndex == 1 && list.getItemAtIndex(0).url == FB_HOME_URL) { - return false - } - if (list.currentIndex > 0) { - goBack() - return true - } - return false - } - - /** - * If webview is already at the top, refresh - * Otherwise scroll to top - */ - override fun onTabClicked() { - if (scrollY < 5) reloadBase(true) - else scrollToTop() - } - - private fun scrollToTop() { - flingScroll(0, 0) // stop fling - if (scrollY > 10000) - scrollTo(0, 0) - else - smoothScrollTo(0) - } - - private fun smoothScrollTo(y: Int) { - ValueAnimator.ofInt(scrollY, y).apply { - duration = min(abs(scrollY - y), 500).toLong() - interpolator = AnimHolder.fastOutSlowInInterpolator(context) - addUpdateListener { scrollY = it.animatedValue as Int } - start() - } - } - - private fun smoothScrollBy(y: Int) = smoothScrollTo(max(0, scrollY + y)) - - override var active: Boolean = true - set(value) { - if (field == value) return - field = value - if (field) onResume() - else onPause() - } - - override fun reloadTheme() { - reloadThemeSelf() - } - - override fun reloadThemeSelf() { - reload(false) // todo see if there's a better solution - } - - override fun reloadTextSize() { - reloadTextSizeSelf() - } - - override fun reloadTextSizeSelf() { - settings.textZoom = prefs.webTextScaling - } - - override fun destroy() { - (getParent() as? ViewGroup)?.removeView(this) - super.destroy() - } + override fun destroy() { + (getParent() as? ViewGroup)?.removeView(this) + super.destroy() + } } diff --git a/app/src/main/kotlin/com/pitchedapps/frost/views/KPrefTextSeekbar.kt b/app/src/main/kotlin/com/pitchedapps/frost/views/KPrefTextSeekbar.kt index 38ce00347..e6bef4fe7 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/views/KPrefTextSeekbar.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/views/KPrefTextSeekbar.kt @@ -21,44 +21,39 @@ import android.util.TypedValue import ca.allanwang.kau.kpref.activity.items.KPrefSeekbar import com.pitchedapps.frost.R -/** - * Created by Allan Wang on 2017-07-07. - */ +/** Created by Allan Wang on 2017-07-07. */ class KPrefTextSeekbar(builder: KPrefSeekbarContract) : KPrefSeekbar(builder) { - var descOriginalSize = 1f + var descOriginalSize = 1f - init { - with(builder) { - min = 50 - max = 200 - descRes = R.string.web_text_scaling_desc - textViewConfigs = { - minEms = 2 - setOnLongClickListener { - pref = 100 - reloadSelf() - true - } - } + init { + with(builder) { + min = 50 + max = 200 + descRes = R.string.web_text_scaling_desc + textViewConfigs = { + minEms = 2 + setOnLongClickListener { + pref = 100 + reloadSelf() + true } + } } + } - @SuppressLint("MissingSuperCall") - override fun bindView(holder: ViewHolder, payloads: List) { - descOriginalSize = holder.desc?.textSize ?: 1f - builder.toText = { - holder.desc?.setTextSize( - TypedValue.COMPLEX_UNIT_PX, - descOriginalSize * it.toFloat() / 100 - ) - "$it%" - } - super.bindView(holder, payloads) + @SuppressLint("MissingSuperCall") + override fun bindView(holder: ViewHolder, payloads: List) { + descOriginalSize = holder.desc?.textSize ?: 1f + builder.toText = { + holder.desc?.setTextSize(TypedValue.COMPLEX_UNIT_PX, descOriginalSize * it.toFloat() / 100) + "$it%" } + super.bindView(holder, payloads) + } - override fun unbindView(holder: ViewHolder) { - holder.desc?.setTextSize(TypedValue.COMPLEX_UNIT_PX, descOriginalSize) - super.unbindView(holder) - } + override fun unbindView(holder: ViewHolder) { + holder.desc?.setTextSize(TypedValue.COMPLEX_UNIT_PX, descOriginalSize) + super.unbindView(holder) + } } diff --git a/app/src/main/kotlin/com/pitchedapps/frost/views/Keywords.kt b/app/src/main/kotlin/com/pitchedapps/frost/views/Keywords.kt index a0a7e5b17..1d12830a1 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/views/Keywords.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/views/Keywords.kt @@ -42,109 +42,92 @@ import com.pitchedapps.frost.prefs.Prefs import dagger.hilt.android.AndroidEntryPoint import javax.inject.Inject -/** - * Created by Allan Wang on 2017-06-19. - */ +/** Created by Allan Wang on 2017-06-19. */ @AndroidEntryPoint -class Keywords @JvmOverloads constructor( - context: Context, - attrs: AttributeSet? = null, - defStyleAttr: Int = 0 -) : ConstraintLayout(context, attrs, defStyleAttr) { +class Keywords +@JvmOverloads +constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0) : + ConstraintLayout(context, attrs, defStyleAttr) { - @Inject - lateinit var prefs: Prefs + @Inject lateinit var prefs: Prefs - @Inject - lateinit var themeProvider: ThemeProvider + @Inject lateinit var themeProvider: ThemeProvider - val editText: AppCompatEditText by bindView(R.id.edit_text) - val addIcon: ImageView by bindView(R.id.add_icon) - val recycler: RecyclerView by bindView(R.id.recycler) - val adapter = FastItemAdapter() + val editText: AppCompatEditText by bindView(R.id.edit_text) + val addIcon: ImageView by bindView(R.id.add_icon) + val recycler: RecyclerView by bindView(R.id.recycler) + val adapter = FastItemAdapter() - init { - inflate(context, R.layout.view_keywords, this) - editText.tint(themeProvider.textColor) - addIcon.setImageDrawable( - GoogleMaterial.Icon.gmd_add.keywordDrawable( - context, - themeProvider - ) - ) - addIcon.setOnClickListener { - if (editText.text.isNullOrEmpty()) editText.error = - context.string(R.string.empty_keyword) - else { - adapter.add(0, KeywordItem(editText.text.toString(), themeProvider)) - editText.text?.clear() - } + init { + inflate(context, R.layout.view_keywords, this) + editText.tint(themeProvider.textColor) + addIcon.setImageDrawable(GoogleMaterial.Icon.gmd_add.keywordDrawable(context, themeProvider)) + addIcon.setOnClickListener { + if (editText.text.isNullOrEmpty()) editText.error = context.string(R.string.empty_keyword) + else { + adapter.add(0, KeywordItem(editText.text.toString(), themeProvider)) + editText.text?.clear() + } + } + adapter.add(prefs.notificationKeywords.map { KeywordItem(it, themeProvider) }) + recycler.layoutManager = LinearLayoutManager(context) + recycler.adapter = adapter + adapter.addEventHook( + object : ClickEventHook() { + override fun onBind(viewHolder: RecyclerView.ViewHolder): View? = + (viewHolder as? KeywordItem.ViewHolder)?.delete + + override fun onClick( + v: View, + position: Int, + fastAdapter: FastAdapter, + item: KeywordItem + ) { + adapter.remove(position) } - adapter.add(prefs.notificationKeywords.map { KeywordItem(it, themeProvider) }) - recycler.layoutManager = LinearLayoutManager(context) - recycler.adapter = adapter - adapter.addEventHook(object : ClickEventHook() { - override fun onBind(viewHolder: RecyclerView.ViewHolder): View? = - (viewHolder as? KeywordItem.ViewHolder)?.delete + } + ) + } - override fun onClick( - v: View, - position: Int, - fastAdapter: FastAdapter, - item: KeywordItem - ) { - adapter.remove(position) - } - }) - } - - fun save() { - prefs.notificationKeywords = adapter.adapterItems.mapTo(mutableSetOf()) { it.keyword } - } + fun save() { + prefs.notificationKeywords = adapter.adapterItems.mapTo(mutableSetOf()) { it.keyword } + } } private fun IIcon.keywordDrawable(context: Context, themeProvider: ThemeProvider): Drawable = - toDrawable(context, 20, themeProvider.textColor) + toDrawable(context, 20, themeProvider.textColor) -class KeywordItem( - val keyword: String, - private val themeProvider: ThemeProvider -) : AbstractItem() { +class KeywordItem(val keyword: String, private val themeProvider: ThemeProvider) : + AbstractItem() { - override fun getViewHolder(v: View): ViewHolder = ViewHolder(v, themeProvider) + override fun getViewHolder(v: View): ViewHolder = ViewHolder(v, themeProvider) - override val layoutRes: Int - get() = R.layout.item_keyword + override val layoutRes: Int + get() = R.layout.item_keyword - override val type: Int - get() = R.id.item_keyword + override val type: Int + get() = R.id.item_keyword - override fun bindView(holder: ViewHolder, payloads: List) { - super.bindView(holder, payloads) - holder.text.text = keyword - } - - override fun unbindView(holder: ViewHolder) { - super.unbindView(holder) - holder.text.text = null - } - - class ViewHolder( - v: View, - themeProvider: ThemeProvider - ) : RecyclerView.ViewHolder(v) { - - val text: AppCompatTextView by bindView(R.id.keyword_text) - val delete: ImageView by bindView(R.id.keyword_delete) - - init { - text.setTextColor(themeProvider.textColor) - delete.setImageDrawable( - GoogleMaterial.Icon.gmd_delete.keywordDrawable( - itemView.context, - themeProvider - ) - ) - } + override fun bindView(holder: ViewHolder, payloads: List) { + super.bindView(holder, payloads) + holder.text.text = keyword + } + + override fun unbindView(holder: ViewHolder) { + super.unbindView(holder) + holder.text.text = null + } + + class ViewHolder(v: View, themeProvider: ThemeProvider) : RecyclerView.ViewHolder(v) { + + val text: AppCompatTextView by bindView(R.id.keyword_text) + val delete: ImageView by bindView(R.id.keyword_delete) + + init { + text.setTextColor(themeProvider.textColor) + delete.setImageDrawable( + GoogleMaterial.Icon.gmd_delete.keywordDrawable(itemView.context, themeProvider) + ) } + } } diff --git a/app/src/main/kotlin/com/pitchedapps/frost/views/SwipeRefreshLayout.kt b/app/src/main/kotlin/com/pitchedapps/frost/views/SwipeRefreshLayout.kt index f02adcf0f..af75aa925 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/views/SwipeRefreshLayout.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/views/SwipeRefreshLayout.kt @@ -29,76 +29,72 @@ import androidx.swiperefreshlayout.widget.SwipeRefreshLayout.OnChildScrollUpCall import com.pitchedapps.frost.utils.L /** - * Variant that forbids refreshing if child layout is not at the top - * Inspired by https://github.com/slapperwan/gh4a/blob/master/app/src/main/java/com/gh4a/widget/SwipeRefreshLayout.java - * + * Variant that forbids refreshing if child layout is not at the top Inspired by + * https://github.com/slapperwan/gh4a/blob/master/app/src/main/java/com/gh4a/widget/SwipeRefreshLayout.java */ class SwipeRefreshLayout @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null) : - SwipeRefreshLayout(context, attrs) { + SwipeRefreshLayout(context, attrs) { - private var preventRefresh: Boolean = false - private var downY: Float = -1f - private val touchSlop = ViewConfiguration.get(context).scaledTouchSlop + private var preventRefresh: Boolean = false + private var downY: Float = -1f + private val touchSlop = ViewConfiguration.get(context).scaledTouchSlop - private var nestedCanChildScrollUp: OnChildScrollUpCallback? = null + private var nestedCanChildScrollUp: OnChildScrollUpCallback? = null - /** - * Copy of [canChildScrollUp], with additional support if necessary - */ - private val canChildScrollUp = OnChildScrollUpCallback { parent, child -> - nestedCanChildScrollUp?.canChildScrollUp(parent, child) ?: when (child) { - is WebView -> child.canScrollVertically(-1).apply { - L.d { "Webview can scroll up $this" } - } - is ListView -> ListViewCompat.canScrollList(child, -1) - // Supports webviews as well - else -> child?.canScrollVertically(-1) ?: false + /** Copy of [canChildScrollUp], with additional support if necessary */ + private val canChildScrollUp = OnChildScrollUpCallback { parent, child -> + nestedCanChildScrollUp?.canChildScrollUp(parent, child) + ?: when (child) { + is WebView -> child.canScrollVertically(-1).apply { L.d { "Webview can scroll up $this" } } + is ListView -> ListViewCompat.canScrollList(child, -1) + // Supports webviews as well + else -> child?.canScrollVertically(-1) ?: false + } + } + + init { + setOnChildScrollUpCallback(canChildScrollUp) + } + + override fun setOnChildScrollUpCallback(callback: OnChildScrollUpCallback?) { + this.nestedCanChildScrollUp = callback + } + + override fun onInterceptTouchEvent(ev: MotionEvent): Boolean { + if (ev.action != MotionEvent.ACTION_DOWN && preventRefresh) { + return false + } + when (ev.action) { + MotionEvent.ACTION_DOWN -> { + downY = ev.y + preventRefresh = canChildScrollUp() + } + MotionEvent.ACTION_MOVE -> { + if (downY - ev.y > touchSlop) { + preventRefresh = true + return false } + } } + return super.onInterceptTouchEvent(ev) + } - init { - setOnChildScrollUpCallback(canChildScrollUp) - } - - override fun setOnChildScrollUpCallback(callback: OnChildScrollUpCallback?) { - this.nestedCanChildScrollUp = callback - } - - override fun onInterceptTouchEvent(ev: MotionEvent): Boolean { - if (ev.action != MotionEvent.ACTION_DOWN && preventRefresh) { - return false - } - when (ev.action) { - MotionEvent.ACTION_DOWN -> { - downY = ev.y - preventRefresh = canChildScrollUp() - } - MotionEvent.ACTION_MOVE -> { - if (downY - ev.y > touchSlop) { - preventRefresh = true - return false - } - } - } - return super.onInterceptTouchEvent(ev) - } - - override fun onNestedScroll( - target: View, - dxConsumed: Int, - dyConsumed: Int, - dxUnconsumed: Int, - dyUnconsumed: Int - ) { - if (preventRefresh) { - /* - * Ignoring offsetInWindow since - * 1. It doesn't seem to matter in the typical use case - * 2. It isn't being transferred to the underlying array used by the super class - */ - dispatchNestedScroll(dxConsumed, dyConsumed, dxUnconsumed, dyUnconsumed, null) - } else { - super.onNestedScroll(target, dxConsumed, dyConsumed, dxUnconsumed, dyUnconsumed) - } + override fun onNestedScroll( + target: View, + dxConsumed: Int, + dyConsumed: Int, + dxUnconsumed: Int, + dyUnconsumed: Int + ) { + if (preventRefresh) { + /* + * Ignoring offsetInWindow since + * 1. It doesn't seem to matter in the typical use case + * 2. It isn't being transferred to the underlying array used by the super class + */ + dispatchNestedScroll(dxConsumed, dyConsumed, dxUnconsumed, dyUnconsumed, null) + } else { + super.onNestedScroll(target, dxConsumed, dyConsumed, dxUnconsumed, dyUnconsumed) } + } } diff --git a/app/src/main/kotlin/com/pitchedapps/frost/web/DebugWebView.kt b/app/src/main/kotlin/com/pitchedapps/frost/web/DebugWebView.kt index 187e7d4e0..adb6ac2df 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/web/DebugWebView.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/web/DebugWebView.kt @@ -35,10 +35,10 @@ import com.pitchedapps.frost.utils.L import com.pitchedapps.frost.utils.createFreshFile import com.pitchedapps.frost.utils.isFacebookUrl import dagger.hilt.android.AndroidEntryPoint -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext import java.io.File import javax.inject.Inject +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext /** * Created by Allan Wang on 2018-01-05. @@ -46,88 +46,80 @@ import javax.inject.Inject * A barebone webview with a refresh listener */ @AndroidEntryPoint -class DebugWebView @JvmOverloads constructor( - context: Context, - attrs: AttributeSet? = null, - defStyleAttr: Int = 0 -) : WebView(context, attrs, defStyleAttr) { +class DebugWebView +@JvmOverloads +constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0) : + WebView(context, attrs, defStyleAttr) { - @Inject - lateinit var prefs: Prefs + @Inject lateinit var prefs: Prefs - @Inject - lateinit var themeProvider: ThemeProvider + @Inject lateinit var themeProvider: ThemeProvider - var onPageFinished: (String?) -> Unit = {} + var onPageFinished: (String?) -> Unit = {} - init { - setupWebview() + init { + setupWebview() + } + + @SuppressLint("SetJavaScriptEnabled") + private fun setupWebview() { + settings.javaScriptEnabled = true + settings.userAgentString = USER_AGENT + setLayerType(View.LAYER_TYPE_HARDWARE, null) + webViewClient = DebugClient() + @Suppress("DEPRECATION") + isDrawingCacheEnabled = true + } + + /** Fetches a screenshot of the current webview, returning true if successful, false otherwise. */ + suspend fun getScreenshot(output: File): Boolean = + withContext(Dispatchers.IO) { + if (!output.createFreshFile()) { + L.e { "Failed to create ${output.absolutePath} for debug screenshot" } + return@withContext false + } + try { + output.outputStream().use { + @Suppress("DEPRECATION") drawingCache.compress(Bitmap.CompressFormat.PNG, 100, it) + } + L.d { "Created screenshot at ${output.absolutePath}" } + true + } catch (e: Exception) { + L.e { "An error occurred ${e.message}" } + false + } } - @SuppressLint("SetJavaScriptEnabled") - private fun setupWebview() { - settings.javaScriptEnabled = true - settings.userAgentString = USER_AGENT - setLayerType(View.LAYER_TYPE_HARDWARE, null) - webViewClient = DebugClient() - @Suppress("DEPRECATION") - isDrawingCacheEnabled = true + private inner class DebugClient : BaseWebViewClient() { + + override fun onPageFinished(view: WebView, url: String?) { + super.onPageFinished(view, url) + onPageFinished(url) } - /** - * Fetches a screenshot of the current webview, returning true if successful, false otherwise. - */ - suspend fun getScreenshot(output: File): Boolean = withContext(Dispatchers.IO) { - - if (!output.createFreshFile()) { - L.e { "Failed to create ${output.absolutePath} for debug screenshot" } - return@withContext false - } - try { - output.outputStream().use { - @Suppress("DEPRECATION") - drawingCache.compress(Bitmap.CompressFormat.PNG, 100, it) - } - L.d { "Created screenshot at ${output.absolutePath}" } - true - } catch (e: Exception) { - L.e { "An error occurred ${e.message}" } - false - } + private fun injectBackgroundColor() { + setBackgroundColor( + if (url.isFacebookUrl) themeProvider.bgColor.withAlpha(255) else Color.WHITE + ) } - private inner class DebugClient : BaseWebViewClient() { - - override fun onPageFinished(view: WebView, url: String?) { - super.onPageFinished(view, url) - onPageFinished(url) - } - - private fun injectBackgroundColor() { - setBackgroundColor( - if (url.isFacebookUrl) themeProvider.bgColor.withAlpha(255) - else Color.WHITE - ) - } - - override fun onPageCommitVisible(view: WebView, url: String?) { - super.onPageCommitVisible(view, url) - injectBackgroundColor() - if (url.isFacebookUrl) - view.jsInject( -// CssHider.CORE, - CssHider.COMPOSER.maybe(!prefs.showComposer), - CssHider.STORIES.maybe(!prefs.showStories), - CssHider.PEOPLE_YOU_MAY_KNOW.maybe(!prefs.showSuggestedFriends), - CssHider.SUGGESTED_GROUPS.maybe(!prefs.showSuggestedGroups), - themeProvider.injector(ThemeCategory.FACEBOOK), - CssHider.NON_RECENT.maybe( - (url?.contains("?sk=h_chr") ?: false) && - prefs.aggressiveRecents - ), - CssAsset.FullSizeImage.maybe(prefs.fullSizeImage), - prefs = prefs - ) - } + override fun onPageCommitVisible(view: WebView, url: String?) { + super.onPageCommitVisible(view, url) + injectBackgroundColor() + if (url.isFacebookUrl) + view.jsInject( + // CssHider.CORE, + CssHider.COMPOSER.maybe(!prefs.showComposer), + CssHider.STORIES.maybe(!prefs.showStories), + CssHider.PEOPLE_YOU_MAY_KNOW.maybe(!prefs.showSuggestedFriends), + CssHider.SUGGESTED_GROUPS.maybe(!prefs.showSuggestedGroups), + themeProvider.injector(ThemeCategory.FACEBOOK), + CssHider.NON_RECENT.maybe( + (url?.contains("?sk=h_chr") ?: false) && prefs.aggressiveRecents + ), + CssAsset.FullSizeImage.maybe(prefs.fullSizeImage), + prefs = prefs + ) } + } } diff --git a/app/src/main/kotlin/com/pitchedapps/frost/web/FrostChromeClients.kt b/app/src/main/kotlin/com/pitchedapps/frost/web/FrostChromeClients.kt index 90345aa21..d0b1b44ee 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/web/FrostChromeClients.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/web/FrostChromeClients.kt @@ -41,131 +41,116 @@ import com.pitchedapps.frost.views.FrostWebView * Collection of chrome clients */ -/** - * The default chrome client - */ +/** The default chrome client */ class FrostChromeClient( - web: FrostWebView, - private val themeProvider: ThemeProvider, - private val webFileChooser: WebFileChooser, + web: FrostWebView, + private val themeProvider: ThemeProvider, + private val webFileChooser: WebFileChooser, ) : WebChromeClient() { -// private val refresh: SendChannel = web.parent.refreshChannel - private val refreshEmit = web.parent.refreshEmit - private val progressEmit = web.parent.progressEmit - private val titleEmit = web.parent.titleEmit - private val context = web.context!! + // private val refresh: SendChannel = web.parent.refreshChannel + private val refreshEmit = web.parent.refreshEmit + private val progressEmit = web.parent.progressEmit + private val titleEmit = web.parent.titleEmit + private val context = web.context!! - override fun getDefaultVideoPoster(): Bitmap? = - super.getDefaultVideoPoster() - ?: Bitmap.createBitmap(50, 50, Bitmap.Config.ARGB_8888) + override fun getDefaultVideoPoster(): Bitmap? = + super.getDefaultVideoPoster() ?: Bitmap.createBitmap(50, 50, Bitmap.Config.ARGB_8888) - override fun onConsoleMessage(consoleMessage: ConsoleMessage): Boolean { - L.v { "Chrome Console ${consoleMessage.lineNumber()}: ${consoleMessage.message()}" } - return true + override fun onConsoleMessage(consoleMessage: ConsoleMessage): Boolean { + L.v { "Chrome Console ${consoleMessage.lineNumber()}: ${consoleMessage.message()}" } + return true + } + + override fun onReceivedTitle(view: WebView, title: String) { + super.onReceivedTitle(view, title) + if (title.startsWith("http")) return + titleEmit(title) + } + + override fun onProgressChanged(view: WebView, newProgress: Int) { + super.onProgressChanged(view, newProgress) + progressEmit(newProgress) + } + + override fun onShowFileChooser( + webView: WebView, + filePathCallback: ValueCallback?>, + fileChooserParams: FileChooserParams + ): Boolean { + webFileChooser.openMediaPicker(filePathCallback, fileChooserParams) + return true + } + + private fun JsResult.frostCancel() { + cancel() + refreshEmit(false) + progressEmit(100) + } + + override fun onJsAlert(view: WebView, url: String, message: String, result: JsResult): Boolean { + view.context.materialDialog { + title(text = url) + message(text = message) + positiveButton { result.confirm() } + onDismiss { result.frostCancel() } } + return true + } - override fun onReceivedTitle(view: WebView, title: String) { - super.onReceivedTitle(view, title) - if (title.startsWith("http")) return - titleEmit(title) + override fun onJsConfirm(view: WebView, url: String, message: String, result: JsResult): Boolean { + view.context.materialDialog { + title(text = url) + message(text = message) + positiveButton { result.confirm() } + negativeButton { result.frostCancel() } + onDismiss { result.frostCancel() } } + return true + } - override fun onProgressChanged(view: WebView, newProgress: Int) { - super.onProgressChanged(view, newProgress) - progressEmit(newProgress) + override fun onJsBeforeUnload( + view: WebView, + url: String, + message: String, + result: JsResult + ): Boolean { + view.context.materialDialog { + title(text = url) + message(text = message) + positiveButton { result.confirm() } + negativeButton { result.frostCancel() } + onDismiss { result.frostCancel() } } + return true + } - override fun onShowFileChooser( - webView: WebView, - filePathCallback: ValueCallback?>, - fileChooserParams: FileChooserParams - ): Boolean { - webFileChooser.openMediaPicker(filePathCallback, fileChooserParams) - return true + override fun onJsPrompt( + view: WebView, + url: String, + message: String, + defaultValue: String?, + result: JsPromptResult + ): Boolean { + view.context.materialDialog { + title(text = url) + message(text = message) + input(prefill = defaultValue) { _, charSequence -> result.confirm(charSequence.toString()) } + // positive button added through input + negativeButton { result.frostCancel() } + onDismiss { result.frostCancel() } } + return true + } - private fun JsResult.frostCancel() { - cancel() - refreshEmit(false) - progressEmit(100) - } - - override fun onJsAlert( - view: WebView, - url: String, - message: String, - result: JsResult - ): Boolean { - view.context.materialDialog { - title(text = url) - message(text = message) - positiveButton { result.confirm() } - onDismiss { result.frostCancel() } - } - return true - } - - override fun onJsConfirm( - view: WebView, - url: String, - message: String, - result: JsResult - ): Boolean { - view.context.materialDialog { - title(text = url) - message(text = message) - positiveButton { result.confirm() } - negativeButton { result.frostCancel() } - onDismiss { result.frostCancel() } - } - return true - } - - override fun onJsBeforeUnload( - view: WebView, - url: String, - message: String, - result: JsResult - ): Boolean { - view.context.materialDialog { - title(text = url) - message(text = message) - positiveButton { result.confirm() } - negativeButton { result.frostCancel() } - onDismiss { result.frostCancel() } - } - return true - } - - override fun onJsPrompt( - view: WebView, - url: String, - message: String, - defaultValue: String?, - result: JsPromptResult - ): Boolean { - view.context.materialDialog { - title(text = url) - message(text = message) - input(prefill = defaultValue) { _, charSequence -> - result.confirm(charSequence.toString()) - } - // positive button added through input - negativeButton { result.frostCancel() } - onDismiss { result.frostCancel() } - } - return true - } - - override fun onGeolocationPermissionsShowPrompt( - origin: String, - callback: GeolocationPermissions.Callback - ) { - L.i { "Requesting geolocation" } - context.kauRequestPermissions(PERMISSION_ACCESS_FINE_LOCATION) { granted, _ -> - L.i { "Geolocation response received; ${if (granted) "granted" else "denied"}" } - callback(origin, granted, true) - } + override fun onGeolocationPermissionsShowPrompt( + origin: String, + callback: GeolocationPermissions.Callback + ) { + L.i { "Requesting geolocation" } + context.kauRequestPermissions(PERMISSION_ACCESS_FINE_LOCATION) { granted, _ -> + L.i { "Geolocation response received; ${if (granted) "granted" else "denied"}" } + callback(origin, granted, true) } + } } diff --git a/app/src/main/kotlin/com/pitchedapps/frost/web/FrostJSI.kt b/app/src/main/kotlin/com/pitchedapps/frost/web/FrostJSI.kt index 4d92e8c28..5c0b0ba4f 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/web/FrostJSI.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/web/FrostJSI.kt @@ -32,149 +32,141 @@ import com.pitchedapps.frost.utils.isIndependent import com.pitchedapps.frost.utils.launchImageActivity import com.pitchedapps.frost.utils.showWebContextMenu import com.pitchedapps.frost.views.FrostWebView -import kotlinx.coroutines.launch import javax.inject.Inject +import kotlinx.coroutines.launch -/** - * Created by Allan Wang on 2017-06-01. - */ +/** Created by Allan Wang on 2017-06-01. */ @FrostWebScoped -class FrostJSI @Inject internal constructor( - val web: FrostWebView, - private val activity: Activity, - private val fbCookie: FbCookie, - private val prefs: Prefs, - @FrostRefresh private val refreshEmit: FrostEmitter +class FrostJSI +@Inject +internal constructor( + val web: FrostWebView, + private val activity: Activity, + private val fbCookie: FbCookie, + private val prefs: Prefs, + @FrostRefresh private val refreshEmit: FrostEmitter ) { - private val mainActivity: MainActivity? = activity as? MainActivity - private val webActivity: WebOverlayActivityBase? = activity as? WebOverlayActivityBase - private val headerEmit: FrostEmitter? = mainActivity?.headerEmit - private val cookies: List = activity.cookies() + private val mainActivity: MainActivity? = activity as? MainActivity + private val webActivity: WebOverlayActivityBase? = activity as? WebOverlayActivityBase + private val headerEmit: FrostEmitter? = mainActivity?.headerEmit + private val cookies: List = activity.cookies() - /** - * Attempts to load the url in an overlay - * Returns {@code true} if successful, meaning the event is consumed, - * or {@code false} otherwise, meaning the event should be propagated - */ - @JavascriptInterface - fun loadUrl(url: String?): Boolean = if (url == null) false else web.requestWebOverlay(url) + /** + * Attempts to load the url in an overlay Returns {@code true} if successful, meaning the event is + * consumed, or {@code false} otherwise, meaning the event should be propagated + */ + @JavascriptInterface + fun loadUrl(url: String?): Boolean = if (url == null) false else web.requestWebOverlay(url) - @JavascriptInterface - fun loadVideo(url: String?, isGif: Boolean): Boolean = - if (url != null && prefs.enablePip) { - web.post { - (activity as? VideoViewHolder)?.showVideo(url, isGif) - ?: L.e { "Could not load video; contract not implemented" } - } - true - } else { - false - } - - @JavascriptInterface - fun reloadBaseUrl(animate: Boolean) { - L.d { "FrostJSI reload" } - web.post { - web.stopLoading() - web.reloadBase(animate) - } + @JavascriptInterface + fun loadVideo(url: String?, isGif: Boolean): Boolean = + if (url != null && prefs.enablePip) { + web.post { + (activity as? VideoViewHolder)?.showVideo(url, isGif) + ?: L.e { "Could not load video; contract not implemented" } + } + true + } else { + false } - @JavascriptInterface - fun contextMenu(url: String?, text: String?) { - // url will be formatted through webcontext - web.post { - activity.showWebContextMenu( - WebContext(url.takeIf { it.isIndependent }, text), - fbCookie, - prefs - ) - } + @JavascriptInterface + fun reloadBaseUrl(animate: Boolean) { + L.d { "FrostJSI reload" } + web.post { + web.stopLoading() + web.reloadBase(animate) } + } - /** - * Get notified when a stationary long click starts or ends - * This will be used to toggle the main activities viewpager swipe - */ - @JavascriptInterface - fun longClick(start: Boolean) { - mainActivity?.contentBinding?.viewpager?.enableSwipe = !start - if (web.frostWebClient.urlSupportsRefresh) { - web.parent.swipeDisabledByAction = start - } + @JavascriptInterface + fun contextMenu(url: String?, text: String?) { + // url will be formatted through webcontext + web.post { + activity.showWebContextMenu( + WebContext(url.takeIf { it.isIndependent }, text), + fbCookie, + prefs + ) } + } - /** - * Allow or disallow the pull down to refresh action - */ - @JavascriptInterface - fun disableSwipeRefresh(disable: Boolean) { - if (!web.frostWebClient.urlSupportsRefresh) { - return - } - web.parent.swipeDisabledByAction = disable - if (disable) { - // locked onto an input field; ensure content is visible - mainActivity?.collapseAppBar() - } + /** + * Get notified when a stationary long click starts or ends This will be used to toggle the main + * activities viewpager swipe + */ + @JavascriptInterface + fun longClick(start: Boolean) { + mainActivity?.contentBinding?.viewpager?.enableSwipe = !start + if (web.frostWebClient.urlSupportsRefresh) { + web.parent.swipeDisabledByAction = start } + } - @JavascriptInterface - fun loadLogin() { - L.d { "Sign up button found; load login" } - activity.ctxCoroutine.launch { - fbCookie.logout(activity, deleteCookie = false) - } + /** Allow or disallow the pull down to refresh action */ + @JavascriptInterface + fun disableSwipeRefresh(disable: Boolean) { + if (!web.frostWebClient.urlSupportsRefresh) { + return } - - /** - * Launch image overlay - */ - @JavascriptInterface - fun loadImage(imageUrl: String, text: String?) { - activity.launchImageActivity(imageUrl, text) + web.parent.swipeDisabledByAction = disable + if (disable) { + // locked onto an input field; ensure content is visible + mainActivity?.collapseAppBar() } + } - @JavascriptInterface - fun emit(flag: Int) { - web.post { web.frostWebClient.emit(flag) } + @JavascriptInterface + fun loadLogin() { + L.d { "Sign up button found; load login" } + activity.ctxCoroutine.launch { fbCookie.logout(activity, deleteCookie = false) } + } + + /** Launch image overlay */ + @JavascriptInterface + fun loadImage(imageUrl: String, text: String?) { + activity.launchImageActivity(imageUrl, text) + } + + @JavascriptInterface + fun emit(flag: Int) { + web.post { web.frostWebClient.emit(flag) } + } + + @JavascriptInterface + fun isReady() { + if (web.frostWebClient !is FrostWebViewClientMenu) { + L.v { "JSI is ready" } + refreshEmit(false) } + } - @JavascriptInterface - fun isReady() { - if (web.frostWebClient !is FrostWebViewClientMenu) { - L.v { "JSI is ready" } - refreshEmit(false) - } - } + @JavascriptInterface + fun handleHtml(html: String?) { + html ?: return + web.post { web.frostWebClient.handleHtml(html) } + } - @JavascriptInterface - fun handleHtml(html: String?) { - html ?: return - web.post { web.frostWebClient.handleHtml(html) } - } + @JavascriptInterface + fun handleHeader(html: String?) { + html ?: return + headerEmit?.invoke(html) + } - @JavascriptInterface - fun handleHeader(html: String?) { - html ?: return - headerEmit?.invoke(html) - } + @JavascriptInterface + fun allowHorizontalScrolling(enable: Boolean) { + mainActivity?.contentBinding?.viewpager?.enableSwipe = enable + webActivity?.swipeBack?.disallowIntercept = !enable + } - @JavascriptInterface - fun allowHorizontalScrolling(enable: Boolean) { - mainActivity?.contentBinding?.viewpager?.enableSwipe = enable - webActivity?.swipeBack?.disallowIntercept = !enable - } + private var isScrolling = false - private var isScrolling = false + @JavascriptInterface + fun setScrolling(scrolling: Boolean) { + L.v { "Scrolling $scrolling" } + this.isScrolling = scrolling + } - @JavascriptInterface - fun setScrolling(scrolling: Boolean) { - L.v { "Scrolling $scrolling" } - this.isScrolling = scrolling - } - - @JavascriptInterface - fun isScrolling(): Boolean = isScrolling + @JavascriptInterface fun isScrolling(): Boolean = isScrolling } diff --git a/app/src/main/kotlin/com/pitchedapps/frost/web/FrostRequestInterceptor.kt b/app/src/main/kotlin/com/pitchedapps/frost/web/FrostRequestInterceptor.kt index defdcae15..267e3b484 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/web/FrostRequestInterceptor.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/web/FrostRequestInterceptor.kt @@ -21,59 +21,54 @@ import android.webkit.WebResourceResponse import android.webkit.WebView import com.pitchedapps.frost.utils.FrostPglAdBlock import com.pitchedapps.frost.utils.L -import okhttp3.HttpUrl.Companion.toHttpUrlOrNull import java.io.ByteArrayInputStream +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull /** * Created by Allan Wang on 2017-07-13. * - * Handler to decide when a request should be done by us - * This is the crux of Frost's optimizations for the web browser + * Handler to decide when a request should be done by us This is the crux of Frost's optimizations + * for the web browser */ private val blankResource: WebResourceResponse = - WebResourceResponse( - "text/plain", - "utf-8", - ByteArrayInputStream("".toByteArray()) - ) + WebResourceResponse("text/plain", "utf-8", ByteArrayInputStream("".toByteArray())) fun WebView.shouldFrostInterceptRequest(request: WebResourceRequest): WebResourceResponse? { - val requestUrl = request.url?.toString() ?: return null - val httpUrl = requestUrl.toHttpUrlOrNull() ?: return null - val host = httpUrl.host - val url = httpUrl.toString() - if (host.contains("facebook") || host.contains("fbcdn")) return null - if (FrostPglAdBlock.isAd(host)) return blankResource -// if (!shouldLoadImages && !Prefs.loadMediaOnMeteredNetwork && request.isMedia) return blankResource - L.v { "Intercept Request: $host $url" } - return null + val requestUrl = request.url?.toString() ?: return null + val httpUrl = requestUrl.toHttpUrlOrNull() ?: return null + val host = httpUrl.host + val url = httpUrl.toString() + if (host.contains("facebook") || host.contains("fbcdn")) return null + if (FrostPglAdBlock.isAd(host)) return blankResource + // if (!shouldLoadImages && !Prefs.loadMediaOnMeteredNetwork && request.isMedia) return + // blankResource + L.v { "Intercept Request: $host $url" } + return null } -/** - * Wrapper to ensure that null exceptions are not reached - */ +/** Wrapper to ensure that null exceptions are not reached */ fun WebResourceRequest.query(action: (url: String) -> Boolean): Boolean { - return action(url?.path ?: return false) + return action(url?.path ?: return false) } val WebResourceRequest.isImage: Boolean - get() = query { it.contains(".jpg") || it.contains(".png") } + get() = query { it.contains(".jpg") || it.contains(".png") } val WebResourceRequest.isMedia: Boolean - get() = query { it.contains(".jpg") || it.contains(".png") || it.contains("video") } + get() = query { it.contains(".jpg") || it.contains(".png") || it.contains("video") } /** - * Generic filter passthrough - * If Resource is already nonnull, pass it, otherwise check if filter is met and override the response accordingly + * Generic filter passthrough If Resource is already nonnull, pass it, otherwise check if filter is + * met and override the response accordingly */ fun WebResourceResponse?.filter(request: WebResourceRequest, filter: (url: String) -> Boolean) = - filter(request.query { filter(it) }) + filter(request.query { filter(it) }) -fun WebResourceResponse?.filter(filter: Boolean): WebResourceResponse? = this - ?: if (filter) blankResource else null +fun WebResourceResponse?.filter(filter: Boolean): WebResourceResponse? = + this ?: if (filter) blankResource else null fun WebResourceResponse?.filterCss(request: WebResourceRequest): WebResourceResponse? = - filter(request) { it.endsWith(".css") } + filter(request) { it.endsWith(".css") } fun WebResourceResponse?.filterImage(request: WebResourceRequest): WebResourceResponse? = - filter(request.isImage) + filter(request.isImage) diff --git a/app/src/main/kotlin/com/pitchedapps/frost/web/FrostUrlOverlayValidator.kt b/app/src/main/kotlin/com/pitchedapps/frost/web/FrostUrlOverlayValidator.kt index a08422670..1df0790b6 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/web/FrostUrlOverlayValidator.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/web/FrostUrlOverlayValidator.kt @@ -39,76 +39,75 @@ import com.pitchedapps.frost.views.FrostWebView /** * Created by Allan Wang on 2017-08-15. * - * Due to the nature of facebook href's, many links - * cannot be resolved on a new window and must instead - * by loaded in the current page - * This helper method will collect all known cases and launch the overlay accordingly - * Returns [true] (default) if action is consumed, [false] otherwise + * Due to the nature of facebook href's, many links cannot be resolved on a new window and must + * instead by loaded in the current page This helper method will collect all known cases and launch + * the overlay accordingly Returns [true] (default) if action is consumed, [false] otherwise * - * Note that this is not always called on the main thread! - * UI related methods should always be posted or they may not be properly executed. + * Note that this is not always called on the main thread! UI related methods should always be + * posted or they may not be properly executed. * - * If the request already comes from an instance of [WebOverlayActivity], we will then judge - * whether the user agent string should be changed. All propagated results will return false, - * as we have no need of sending a new intent to the same activity + * If the request already comes from an instance of [WebOverlayActivity], we will then judge whether + * the user agent string should be changed. All propagated results will return false, as we have no + * need of sending a new intent to the same activity */ fun FrostWebView.requestWebOverlay(url: String): Boolean { - @Suppress("NAME_SHADOWING") val url = url.formattedFbUrl - L.v { "Request web overlay: $url" } - val context = context // finalize reference - if (url.isVideoUrl && context is VideoViewHolder) { - L.d { "Found video through overlay" } - context.runOnUiThread { context.showVideo(url) } - return true - } - if (url.isIndirectImageUrl) { - L.d { "Found indirect fb image" } - context.launchImageActivity(url, cookie = fbCookie.webCookie) - return true - } - if (url.isImageUrl) { - L.d { "Found fb image" } - context.launchImageActivity(url) - return true - } - if (!url.isIndependent) { - L.d { "Forbid overlay switch" } - return false - } - if (!prefs.overlayEnabled) return false - if (context is WebOverlayActivityBase) { - val shouldUseDesktop = url.isFacebookUrl || url.isMessengerUrl - // already overlay; manage user agent - if (userAgentString != USER_AGENT_DESKTOP_CONST && shouldUseDesktop) { - L._i { "Switch to desktop agent overlay" } - context.launchWebOverlayDesktop(url, fbCookie, prefs) - return true - } - if (userAgentString == USER_AGENT_DESKTOP_CONST && !shouldUseDesktop) { - L._i { "Switch from desktop agent" } - context.launchWebOverlayMobile(url, fbCookie, prefs) - return true - } - L._i { "return false switch" } - return false - } - L.v { "Request web overlay passed" } - context.launchWebOverlay(url, fbCookie, prefs) + @Suppress("NAME_SHADOWING") val url = url.formattedFbUrl + L.v { "Request web overlay: $url" } + val context = context // finalize reference + if (url.isVideoUrl && context is VideoViewHolder) { + L.d { "Found video through overlay" } + context.runOnUiThread { context.showVideo(url) } return true + } + if (url.isIndirectImageUrl) { + L.d { "Found indirect fb image" } + context.launchImageActivity(url, cookie = fbCookie.webCookie) + return true + } + if (url.isImageUrl) { + L.d { "Found fb image" } + context.launchImageActivity(url) + return true + } + if (!url.isIndependent) { + L.d { "Forbid overlay switch" } + return false + } + if (!prefs.overlayEnabled) return false + if (context is WebOverlayActivityBase) { + val shouldUseDesktop = url.isFacebookUrl || url.isMessengerUrl + // already overlay; manage user agent + if (userAgentString != USER_AGENT_DESKTOP_CONST && shouldUseDesktop) { + L._i { "Switch to desktop agent overlay" } + context.launchWebOverlayDesktop(url, fbCookie, prefs) + return true + } + if (userAgentString == USER_AGENT_DESKTOP_CONST && !shouldUseDesktop) { + L._i { "Switch from desktop agent" } + context.launchWebOverlayMobile(url, fbCookie, prefs) + return true + } + L._i { "return false switch" } + return false + } + L.v { "Request web overlay passed" } + context.launchWebOverlay(url, fbCookie, prefs) + return true } -/** - * If the url contains any one of the whitelist segments, switch to the chat overlay - */ +/** If the url contains any one of the whitelist segments, switch to the chat overlay */ val messageWhitelist: Set = - setOf(FbItem.MESSAGES, FbItem.CHAT, FbItem.FEED_MOST_RECENT, FbItem.FEED_TOP_STORIES) - .mapTo(mutableSetOf(), FbItem::url) + setOf(FbItem.MESSAGES, FbItem.CHAT, FbItem.FEED_MOST_RECENT, FbItem.FEED_TOP_STORIES) + .mapTo(mutableSetOf(), FbItem::url) -@Deprecated(message = "Should not be used in production as we only support one user agent at a time.") +@Deprecated( + message = "Should not be used in production as we only support one user agent at a time." +) val String.shouldUseDesktopAgent: Boolean - get() = when { - contains("story.php") -> false // do not use desktop for comment section - contains("/events/") -> false // do not use for events (namely the map) - contains("/messages") -> true // must use for messages - else -> false // default to normal user agent + get() = + when { + contains("story.php") -> false // do not use desktop for comment section + contains("/events/") -> false // do not use for events (namely the map) + contains("/messages") -> true // must use for messages + else -> false // default to normal user agent } diff --git a/app/src/main/kotlin/com/pitchedapps/frost/web/FrostWeb.kt b/app/src/main/kotlin/com/pitchedapps/frost/web/FrostWeb.kt index ba05a2c46..9ec53b193 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/web/FrostWeb.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/web/FrostWeb.kt @@ -25,10 +25,10 @@ import dagger.hilt.DefineComponent import dagger.hilt.EntryPoint import dagger.hilt.InstallIn import dagger.hilt.android.components.ViewComponent -import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.SharedFlow import javax.inject.Qualifier import javax.inject.Scope +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow /** * Defines a new scope for Frost web related content. @@ -37,28 +37,22 @@ import javax.inject.Scope */ @Scope @Retention(AnnotationRetention.BINARY) -@Target( - AnnotationTarget.FUNCTION, - AnnotationTarget.TYPE, - AnnotationTarget.CLASS -) +@Target(AnnotationTarget.FUNCTION, AnnotationTarget.TYPE, AnnotationTarget.CLASS) annotation class FrostWebScoped -@FrostWebScoped -@DefineComponent(parent = ViewComponent::class) -interface FrostWebComponent +@FrostWebScoped @DefineComponent(parent = ViewComponent::class) interface FrostWebComponent @DefineComponent.Builder interface FrostWebComponentBuilder { - fun frostParent(@BindsInstance parent: FrostContentParent): FrostWebComponentBuilder - fun frostWebView(@BindsInstance web: FrostWebView): FrostWebComponentBuilder - fun build(): FrostWebComponent + fun frostParent(@BindsInstance parent: FrostContentParent): FrostWebComponentBuilder + fun frostWebView(@BindsInstance web: FrostWebView): FrostWebComponentBuilder + fun build(): FrostWebComponent } @EntryPoint @InstallIn(FrostWebComponent::class) interface FrostWebEntryPoint { - fun frostJsi(): FrostJSI + fun frostJsi(): FrostJSI } fun interface FrostEmitter : (T) -> Unit @@ -68,20 +62,16 @@ fun MutableSharedFlow.asFrostEmitter(): FrostEmitter = FrostEmitter { @Module @InstallIn(FrostWebComponent::class) object FrostWebFlowModule { - @Provides - @FrostWebScoped - @FrostRefresh - fun refreshFlow(parent: FrostContentParent): SharedFlow = parent.refreshFlow + @Provides + @FrostWebScoped + @FrostRefresh + fun refreshFlow(parent: FrostContentParent): SharedFlow = parent.refreshFlow - @Provides - @FrostWebScoped - @FrostRefresh - fun refreshEmit(parent: FrostContentParent): FrostEmitter = parent.refreshEmit + @Provides + @FrostWebScoped + @FrostRefresh + fun refreshEmit(parent: FrostContentParent): FrostEmitter = parent.refreshEmit } -/** - * Observable to get data on whether view is refreshing or not - */ -@Qualifier -@Retention(AnnotationRetention.BINARY) -annotation class FrostRefresh +/** Observable to get data on whether view is refreshing or not */ +@Qualifier @Retention(AnnotationRetention.BINARY) annotation class FrostRefresh diff --git a/app/src/main/kotlin/com/pitchedapps/frost/web/FrostWebViewClients.kt b/app/src/main/kotlin/com/pitchedapps/frost/web/FrostWebViewClients.kt index ba19989d4..332bae128 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/web/FrostWebViewClients.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/web/FrostWebViewClients.kt @@ -61,222 +61,208 @@ import kotlinx.coroutines.launch * Collection of webview clients */ -/** - * The base of all webview clients - * Used to ensure that resources are properly intercepted - */ +/** The base of all webview clients Used to ensure that resources are properly intercepted */ open class BaseWebViewClient : WebViewClient() { - override fun shouldInterceptRequest( - view: WebView, - request: WebResourceRequest - ): WebResourceResponse? = - view.shouldFrostInterceptRequest(request) + override fun shouldInterceptRequest( + view: WebView, + request: WebResourceRequest + ): WebResourceResponse? = view.shouldFrostInterceptRequest(request) } -/** - * The default webview client - */ +/** The default webview client */ open class FrostWebViewClient(val web: FrostWebView) : BaseWebViewClient() { - protected val fbCookie: FbCookie get() = web.fbCookie - protected val prefs: Prefs get() = web.prefs - protected val themeProvider: ThemeProvider get() = web.themeProvider -// protected val refresh: SendChannel = web.parent.refreshChannel - protected val isMain = web.parent.baseEnum != null + protected val fbCookie: FbCookie + get() = web.fbCookie + protected val prefs: Prefs + get() = web.prefs + protected val themeProvider: ThemeProvider + get() = web.themeProvider + // protected val refresh: SendChannel = web.parent.refreshChannel + protected val isMain = web.parent.baseEnum != null - /** - * True if current url supports refresh. See [doUpdateVisitedHistory] for updates - */ - internal var urlSupportsRefresh: Boolean = true + /** True if current url supports refresh. See [doUpdateVisitedHistory] for updates */ + internal var urlSupportsRefresh: Boolean = true - override fun doUpdateVisitedHistory(view: WebView, url: String?, isReload: Boolean) { - super.doUpdateVisitedHistory(view, url, isReload) - urlSupportsRefresh = urlSupportsRefresh(url) - web.parent.swipeAllowedByPage = urlSupportsRefresh - view.jsInject( - JsAssets.AUTO_RESIZE_TEXTAREA.maybe(prefs.autoExpandTextBox), - prefs = prefs - ) - v { "History $url; refresh $urlSupportsRefresh" } - } + override fun doUpdateVisitedHistory(view: WebView, url: String?, isReload: Boolean) { + super.doUpdateVisitedHistory(view, url, isReload) + urlSupportsRefresh = urlSupportsRefresh(url) + web.parent.swipeAllowedByPage = urlSupportsRefresh + view.jsInject(JsAssets.AUTO_RESIZE_TEXTAREA.maybe(prefs.autoExpandTextBox), prefs = prefs) + v { "History $url; refresh $urlSupportsRefresh" } + } - private fun urlSupportsRefresh(url: String?): Boolean { - if (url == null) return false - if (url.isMessengerUrl) return false - if (!url.isFacebookUrl) return true - if (url.contains("soft=composer")) return false - if (url.contains("sharer.php") || url.contains("sharer-dialog.php")) return false - return true - } + private fun urlSupportsRefresh(url: String?): Boolean { + if (url == null) return false + if (url.isMessengerUrl) return false + if (!url.isFacebookUrl) return true + if (url.contains("soft=composer")) return false + if (url.contains("sharer.php") || url.contains("sharer-dialog.php")) return false + return true + } - protected inline fun v(crossinline message: () -> Any?) = L.v { "web client: ${message()}" } + protected inline fun v(crossinline message: () -> Any?) = L.v { "web client: ${message()}" } - /** - * Main injections for facebook content - */ - protected open val facebookJsInjectors: List = listOf( - // CssHider.CORE, - CssHider.HEADER, - CssHider.COMPOSER.maybe(!prefs.showComposer), - CssHider.STORIES.maybe(!prefs.showStories), - CssHider.PEOPLE_YOU_MAY_KNOW.maybe(!prefs.showSuggestedFriends), - CssHider.SUGGESTED_GROUPS.maybe(!prefs.showSuggestedGroups), - themeProvider.injector(ThemeCategory.FACEBOOK), - CssHider.NON_RECENT.maybe( - (web.url?.contains("?sk=h_chr") ?: false) && - prefs.aggressiveRecents - ), - CssHider.ADS.maybe(!prefs.showFacebookAds), - CssHider.POST_ACTIONS.maybe(!prefs.showPostActions), - CssHider.POST_REACTIONS.maybe(!prefs.showPostReactions), - CssAsset.FullSizeImage.maybe(prefs.fullSizeImage), - JsAssets.DOCUMENT_WATCHER, - JsAssets.HORIZONTAL_SCROLLING, - JsAssets.AUTO_RESIZE_TEXTAREA.maybe(prefs.autoExpandTextBox), - JsAssets.CLICK_A, - JsAssets.CONTEXT_A, - JsAssets.MEDIA, - JsAssets.SCROLL_STOP, + /** Main injections for facebook content */ + protected open val facebookJsInjectors: List = + listOf( + // CssHider.CORE, + CssHider.HEADER, + CssHider.COMPOSER.maybe(!prefs.showComposer), + CssHider.STORIES.maybe(!prefs.showStories), + CssHider.PEOPLE_YOU_MAY_KNOW.maybe(!prefs.showSuggestedFriends), + CssHider.SUGGESTED_GROUPS.maybe(!prefs.showSuggestedGroups), + themeProvider.injector(ThemeCategory.FACEBOOK), + CssHider.NON_RECENT.maybe( + (web.url?.contains("?sk=h_chr") ?: false) && prefs.aggressiveRecents + ), + CssHider.ADS.maybe(!prefs.showFacebookAds), + CssHider.POST_ACTIONS.maybe(!prefs.showPostActions), + CssHider.POST_REACTIONS.maybe(!prefs.showPostReactions), + CssAsset.FullSizeImage.maybe(prefs.fullSizeImage), + JsAssets.DOCUMENT_WATCHER, + JsAssets.HORIZONTAL_SCROLLING, + JsAssets.AUTO_RESIZE_TEXTAREA.maybe(prefs.autoExpandTextBox), + JsAssets.CLICK_A, + JsAssets.CONTEXT_A, + JsAssets.MEDIA, + JsAssets.SCROLL_STOP, ) - private fun WebView.facebookJsInject() { - jsInject(*facebookJsInjectors.toTypedArray(), prefs = prefs) - } + private fun WebView.facebookJsInject() { + jsInject(*facebookJsInjectors.toTypedArray(), prefs = prefs) + } - private fun WebView.messengerJsInject() { - jsInject( - themeProvider.injector(ThemeCategory.MESSENGER), - prefs = prefs + private fun WebView.messengerJsInject() { + jsInject(themeProvider.injector(ThemeCategory.MESSENGER), prefs = prefs) + } + + override fun onPageStarted(view: WebView, url: String?, favicon: Bitmap?) { + super.onPageStarted(view, url, favicon) + if (url == null) return + v { "loading $url ${web.settings.userAgentString}" } + // refresh.offer(true) + } + + private fun injectBackgroundColor() { + web.setBackgroundColor( + when { + isMain -> Color.TRANSPARENT + web.url.isFacebookUrl -> themeProvider.bgColor.withAlpha(255) + else -> Color.WHITE + } + ) + } + + override fun onPageCommitVisible(view: WebView, url: String?) { + super.onPageCommitVisible(view, url) + injectBackgroundColor() + when { + url.isFacebookUrl -> { + v { "FB Page commit visible" } + view.facebookJsInject() + } + url.isMessengerUrl -> { + v { "Messenger Page commit visible" } + view.messengerJsInject() + } + else -> { + // refresh.offer(false) + } + } + } + + override fun onPageFinished(view: WebView, url: String?) { + url ?: return + v { "finished $url" } + if (!url.isFacebookUrl && !url.isMessengerUrl) { + // refresh.offer(false) + return + } + onPageFinishedActions(url) + } + + internal open fun onPageFinishedActions(url: String) { + if (url.startsWith("${FbItem.MESSAGES.url}/read/") && prefs.messageScrollToBottom) { + web.pageDown(true) + } + injectAndFinish() + } + + // Temp open + internal open fun injectAndFinish() { + v { "page finished reveal" } + // refresh.offer(false) + injectBackgroundColor() + when { + web.url.isFacebookUrl -> { + web.jsInject( + JsActions.LOGIN_CHECK, + JsAssets.TEXTAREA_LISTENER, + JsAssets.HEADER_BADGES.maybe(isMain), + prefs = prefs ) + web.facebookJsInject() + } + web.url.isMessengerUrl -> { + web.messengerJsInject() + } } + } - override fun onPageStarted(view: WebView, url: String?, favicon: Bitmap?) { - super.onPageStarted(view, url, favicon) - if (url == null) return - v { "loading $url ${web.settings.userAgentString}" } -// refresh.offer(true) - } + open fun handleHtml(html: String?) { + L.d { "Handle Html" } + } - private fun injectBackgroundColor() { - web.setBackgroundColor( - when { - isMain -> Color.TRANSPARENT - web.url.isFacebookUrl -> themeProvider.bgColor.withAlpha(255) - else -> Color.WHITE - } - ) - } + open fun emit(flag: Int) { + L.d { "Emit $flag" } + } - override fun onPageCommitVisible(view: WebView, url: String?) { - super.onPageCommitVisible(view, url) - injectBackgroundColor() - when { - url.isFacebookUrl -> { - v { "FB Page commit visible" } - view.facebookJsInject() - } - url.isMessengerUrl -> { - v { "Messenger Page commit visible" } - view.messengerJsInject() - } - else -> { -// refresh.offer(false) - } - } - } + /** + * Helper to format the request and launch it returns true to override the url returns false if we + * are already in an overlaying activity + */ + private fun launchRequest(request: WebResourceRequest): Boolean { + v { "Launching url: ${request.url}" } + return web.requestWebOverlay(request.url.toString()) + } - override fun onPageFinished(view: WebView, url: String?) { - url ?: return - v { "finished $url" } - if (!url.isFacebookUrl && !url.isMessengerUrl) { -// refresh.offer(false) - return - } - onPageFinishedActions(url) - } + private fun launchImage(url: String, text: String? = null, cookie: String? = null): Boolean { + v { "Launching image: $url" } + web.context.launchImageActivity(url, text, cookie) + if (web.canGoBack()) web.goBack() + return true + } - internal open fun onPageFinishedActions(url: String) { - if (url.startsWith("${FbItem.MESSAGES.url}/read/") && prefs.messageScrollToBottom) { - web.pageDown(true) - } - injectAndFinish() + override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest): Boolean { + v { "Url loading: ${request.url}" } + val path = request.url?.path ?: return super.shouldOverrideUrlLoading(view, request) + v { "Url path $path" } + val url = request.url.toString() + if (url.isExplicitIntent) { + view.context.startActivityForUri(request.url) + return true } - - // Temp open - internal open fun injectAndFinish() { - v { "page finished reveal" } -// refresh.offer(false) - injectBackgroundColor() - when { - web.url.isFacebookUrl -> { - web.jsInject( - JsActions.LOGIN_CHECK, - JsAssets.TEXTAREA_LISTENER, - JsAssets.HEADER_BADGES.maybe(isMain), - prefs = prefs - ) - web.facebookJsInject() - } - web.url.isMessengerUrl -> { - web.messengerJsInject() - } - } + if (path.startsWith("/composer/")) { + return launchRequest(request) } - - open fun handleHtml(html: String?) { - L.d { "Handle Html" } + if (url.isIndirectImageUrl) { + return launchImage(url.formattedFbUrl, cookie = fbCookie.webCookie) } - - open fun emit(flag: Int) { - L.d { "Emit $flag" } + if (url.isImageUrl) { + return launchImage(url.formattedFbUrl) } - - /** - * Helper to format the request and launch it - * returns true to override the url - * returns false if we are already in an overlaying activity - */ - private fun launchRequest(request: WebResourceRequest): Boolean { - v { "Launching url: ${request.url}" } - return web.requestWebOverlay(request.url.toString()) + if (prefs.linksInDefaultApp && view.context.startActivityForUri(request.url)) { + return true } - - private fun launchImage(url: String, text: String? = null, cookie: String? = null): Boolean { - v { "Launching image: $url" } - web.context.launchImageActivity(url, text, cookie) - if (web.canGoBack()) web.goBack() - return true - } - - override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest): Boolean { - v { "Url loading: ${request.url}" } - val path = request.url?.path ?: return super.shouldOverrideUrlLoading(view, request) - v { "Url path $path" } - val url = request.url.toString() - if (url.isExplicitIntent) { - view.context.startActivityForUri(request.url) - return true - } - if (path.startsWith("/composer/")) { - return launchRequest(request) - } - if (url.isIndirectImageUrl) { - return launchImage(url.formattedFbUrl, cookie = fbCookie.webCookie) - } - if (url.isImageUrl) { - return launchImage(url.formattedFbUrl) - } - if (prefs.linksInDefaultApp && view.context.startActivityForUri(request.url)) { - return true - } - // Convert desktop urls to mobile ones - if (url.contains("https://www.facebook.com") && urlSupportsRefresh(url)) { - view.loadUrl(url.replace(WWW_FACEBOOK_COM, FACEBOOK_BASE_COM)) - return true - } - return super.shouldOverrideUrlLoading(view, request) + // Convert desktop urls to mobile ones + if (url.contains("https://www.facebook.com") && urlSupportsRefresh(url)) { + view.loadUrl(url.replace(WWW_FACEBOOK_COM, FACEBOOK_BASE_COM)) + return true } + return super.shouldOverrideUrlLoading(view, request) + } } private const val EMIT_THEME = 0b1 @@ -284,82 +270,80 @@ private const val EMIT_ID = 0b10 private const val EMIT_COMPLETE = EMIT_THEME or EMIT_ID private const val EMIT_FINISH = 0 -/** - * Client variant for the menu view - */ +/** Client variant for the menu view */ class FrostWebViewClientMenu(web: FrostWebView) : FrostWebViewClient(web) { - override fun onPageFinished(view: WebView, url: String?) { - super.onPageFinished(view, url) - if (url == null) { - return - } - jsInject(JsAssets.MENU, prefs = prefs) + override fun onPageFinished(view: WebView, url: String?) { + super.onPageFinished(view, url) + if (url == null) { + return } + jsInject(JsAssets.MENU, prefs = prefs) + } - /* - * We do not inject headers as they include the menu flyout. - * Instead, we remove the flyout margins within the js script so that it covers the header. - */ - override val facebookJsInjectors: List = - super.facebookJsInjectors - CssHider.HEADER + CssAsset.Menu + /* + * We do not inject headers as they include the menu flyout. + * Instead, we remove the flyout margins within the js script so that it covers the header. + */ + override val facebookJsInjectors: List = + super.facebookJsInjectors - CssHider.HEADER + CssAsset.Menu - override fun emit(flag: Int) { - super.emit(flag) - when (flag) { - EMIT_FINISH -> { - super.injectAndFinish() - } - } + override fun emit(flag: Int) { + super.emit(flag) + when (flag) { + EMIT_FINISH -> { + super.injectAndFinish() + } } + } - /* - * Facebook doesn't properly load back to the menu even in standard browsers. - * Instead, if we detect the base soft url, we will manually click the menu item - */ - override fun doUpdateVisitedHistory(view: WebView, url: String?, isReload: Boolean) { - super.doUpdateVisitedHistory(view, url, isReload) - if (url?.startsWith(FbItem.MENU.url) == true) { - jsInject(JsAssets.MENU_QUICK, prefs = prefs) - } + /* + * Facebook doesn't properly load back to the menu even in standard browsers. + * Instead, if we detect the base soft url, we will manually click the menu item + */ + override fun doUpdateVisitedHistory(view: WebView, url: String?, isReload: Boolean) { + super.doUpdateVisitedHistory(view, url, isReload) + if (url?.startsWith(FbItem.MENU.url) == true) { + jsInject(JsAssets.MENU_QUICK, prefs = prefs) } + } - override fun onPageFinishedActions(url: String) { - // Skip - } + override fun onPageFinishedActions(url: String) { + // Skip + } } class FrostWebViewClientMessenger(web: FrostWebView) : FrostWebViewClient(web) { - override fun onPageFinished(view: WebView, url: String?) { - super.onPageFinished(view, url) - messengerCookieCheck(url!!) - } + override fun onPageFinished(view: WebView, url: String?) { + super.onPageFinished(view, url) + messengerCookieCheck(url!!) + } - private val cookieDao: CookieDao get() = web.cookieDao - private var hasCookie = fbCookie.messengerCookie.isFbCookie + private val cookieDao: CookieDao + get() = web.cookieDao + private var hasCookie = fbCookie.messengerCookie.isFbCookie - /** - * Check cookie changes. Unlike fb checks, we will continuously poll for cookie changes during loading. - * There is no lifecycle association between messenger login and facebook login, - * so we'll try to be smart about when to check for state changes. - * - * From testing, it looks like this is called after redirects. - * We can therefore classify no login as pointing to messenger.com, - * and login as pointing to messenger.com/t/[thread id] - */ - private fun messengerCookieCheck(url: String?) { - if (url?.startsWith(HTTPS_MESSENGER_COM) != true) return - val shouldHaveCookie = url.startsWith(MESSENGER_THREAD_PREFIX) - L._d { "Messenger client: $url $shouldHaveCookie" } - if (shouldHaveCookie == hasCookie) return - hasCookie = shouldHaveCookie - web.context.ctxCoroutine.launch { - cookieDao.updateMessengerCookie( - prefs.userId, - if (shouldHaveCookie) fbCookie.messengerCookie else null - ) - L._d { "New cookie ${cookieDao.currentCookie(prefs)?.toSensitiveString()}" } - } + /** + * Check cookie changes. Unlike fb checks, we will continuously poll for cookie changes during + * loading. There is no lifecycle association between messenger login and facebook login, so we'll + * try to be smart about when to check for state changes. + * + * From testing, it looks like this is called after redirects. We can therefore classify no login + * as pointing to messenger.com, and login as pointing to messenger.com/t/[thread id] + */ + private fun messengerCookieCheck(url: String?) { + if (url?.startsWith(HTTPS_MESSENGER_COM) != true) return + val shouldHaveCookie = url.startsWith(MESSENGER_THREAD_PREFIX) + L._d { "Messenger client: $url $shouldHaveCookie" } + if (shouldHaveCookie == hasCookie) return + hasCookie = shouldHaveCookie + web.context.ctxCoroutine.launch { + cookieDao.updateMessengerCookie( + prefs.userId, + if (shouldHaveCookie) fbCookie.messengerCookie else null + ) + L._d { "New cookie ${cookieDao.currentCookie(prefs)?.toSensitiveString()}" } } + } } diff --git a/app/src/main/kotlin/com/pitchedapps/frost/web/FrostWebViewClients2.kt b/app/src/main/kotlin/com/pitchedapps/frost/web/FrostWebViewClients2.kt index 008b11976..533d872b8 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/web/FrostWebViewClients2.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/web/FrostWebViewClients2.kt @@ -42,152 +42,136 @@ import javax.inject.Qualifier * * Collection of webview clients */ - -@Qualifier -@Retention(AnnotationRetention.BINARY) -annotation class FrostWebClient +@Qualifier @Retention(AnnotationRetention.BINARY) annotation class FrostWebClient @EntryPoint @InstallIn(FrostWebComponent::class) interface FrostWebClientEntryPoint { - @FrostWebScoped - @FrostWebClient - fun webClient(): FrostWebViewClient + @FrostWebScoped @FrostWebClient fun webClient(): FrostWebViewClient } @Module @InstallIn(FrostWebComponent::class) interface FrostWebViewClientModule { - @Binds - @FrostWebClient - fun webClient(binds: FrostWebViewClient2): FrostWebViewClient + @Binds @FrostWebClient fun webClient(binds: FrostWebViewClient2): FrostWebViewClient } -/** - * The default webview client - */ -open class FrostWebViewClient2 @Inject constructor( - web: FrostWebView, - @FrostRefresh private val refreshEmit: FrostEmitter -) : FrostWebViewClient(web) { +/** The default webview client */ +open class FrostWebViewClient2 +@Inject +constructor(web: FrostWebView, @FrostRefresh private val refreshEmit: FrostEmitter) : + FrostWebViewClient(web) { - init { - L.i { "Refresh web client 2" } - } + init { + L.i { "Refresh web client 2" } + } - override fun doUpdateVisitedHistory(view: WebView, url: String?, isReload: Boolean) { - super.doUpdateVisitedHistory(view, url, isReload) - urlSupportsRefresh = urlSupportsRefresh(url) - web.parent.swipeAllowedByPage = urlSupportsRefresh - view.jsInject( - JsAssets.AUTO_RESIZE_TEXTAREA.maybe(prefs.autoExpandTextBox), - prefs = prefs - ) - v { "History $url; refresh $urlSupportsRefresh" } - } + override fun doUpdateVisitedHistory(view: WebView, url: String?, isReload: Boolean) { + super.doUpdateVisitedHistory(view, url, isReload) + urlSupportsRefresh = urlSupportsRefresh(url) + web.parent.swipeAllowedByPage = urlSupportsRefresh + view.jsInject(JsAssets.AUTO_RESIZE_TEXTAREA.maybe(prefs.autoExpandTextBox), prefs = prefs) + v { "History $url; refresh $urlSupportsRefresh" } + } - private fun urlSupportsRefresh(url: String?): Boolean { - if (url == null) return false - if (url.isMessengerUrl) return false - if (!url.isFacebookUrl) return true - if (url.contains("soft=composer")) return false - if (url.contains("sharer.php") || url.contains("sharer-dialog.php")) return false - return true - } + private fun urlSupportsRefresh(url: String?): Boolean { + if (url == null) return false + if (url.isMessengerUrl) return false + if (!url.isFacebookUrl) return true + if (url.contains("soft=composer")) return false + if (url.contains("sharer.php") || url.contains("sharer-dialog.php")) return false + return true + } - private fun WebView.facebookJsInject() { - jsInject(*facebookJsInjectors.toTypedArray(), prefs = prefs) - } + private fun WebView.facebookJsInject() { + jsInject(*facebookJsInjectors.toTypedArray(), prefs = prefs) + } - private fun WebView.messengerJsInject() { - jsInject( - themeProvider.injector(ThemeCategory.MESSENGER), - prefs = prefs - ) - } + private fun WebView.messengerJsInject() { + jsInject(themeProvider.injector(ThemeCategory.MESSENGER), prefs = prefs) + } - override fun onPageStarted(view: WebView, url: String?, favicon: Bitmap?) { - super.onPageStarted(view, url, favicon) - if (url == null) return - v { "loading $url ${web.settings.userAgentString}" } - refreshEmit(true) - } + override fun onPageStarted(view: WebView, url: String?, favicon: Bitmap?) { + super.onPageStarted(view, url, favicon) + if (url == null) return + v { "loading $url ${web.settings.userAgentString}" } + refreshEmit(true) + } - private fun injectBackgroundColor() { - web.setBackgroundColor( - when { - isMain -> Color.TRANSPARENT - web.url.isFacebookUrl -> themeProvider.bgColor.withAlpha(255) - else -> Color.WHITE - } - ) - } + private fun injectBackgroundColor() { + web.setBackgroundColor( + when { + isMain -> Color.TRANSPARENT + web.url.isFacebookUrl -> themeProvider.bgColor.withAlpha(255) + else -> Color.WHITE + } + ) + } - override fun onPageCommitVisible(view: WebView, url: String?) { - super.onPageCommitVisible(view, url) - injectBackgroundColor() - when { - url.isFacebookUrl -> { - v { "FB Page commit visible" } - view.facebookJsInject() - } - url.isMessengerUrl -> { - v { "Messenger Page commit visible" } - view.messengerJsInject() - } - else -> { - refreshEmit(false) - } - } - } - - override fun onPageFinished(view: WebView, url: String?) { - url ?: return - v { "finished $url" } - if (!url.isFacebookUrl && !url.isMessengerUrl) { - refreshEmit(false) - return - } - onPageFinishedActions(url) - } - - internal override fun injectAndFinish() { - v { "page finished reveal" } + override fun onPageCommitVisible(view: WebView, url: String?) { + super.onPageCommitVisible(view, url) + injectBackgroundColor() + when { + url.isFacebookUrl -> { + v { "FB Page commit visible" } + view.facebookJsInject() + } + url.isMessengerUrl -> { + v { "Messenger Page commit visible" } + view.messengerJsInject() + } + else -> { refreshEmit(false) - injectBackgroundColor() - when { - web.url.isFacebookUrl -> { - web.jsInject( - JsActions.LOGIN_CHECK, - JsAssets.TEXTAREA_LISTENER, - JsAssets.HEADER_BADGES.maybe(isMain), - prefs = prefs - ) - web.facebookJsInject() - } - web.url.isMessengerUrl -> { - web.messengerJsInject() - } - } + } } + } - /** - * Helper to format the request and launch it - * returns true to override the url - * returns false if we are already in an overlaying activity - */ - private fun launchRequest(request: WebResourceRequest): Boolean { - v { "Launching url: ${request.url}" } - return web.requestWebOverlay(request.url.toString()) + override fun onPageFinished(view: WebView, url: String?) { + url ?: return + v { "finished $url" } + if (!url.isFacebookUrl && !url.isMessengerUrl) { + refreshEmit(false) + return } + onPageFinishedActions(url) + } - private fun launchImage(url: String, text: String? = null, cookie: String? = null): Boolean { - v { "Launching image: $url" } - web.context.launchImageActivity(url, text, cookie) - if (web.canGoBack()) web.goBack() - return true + internal override fun injectAndFinish() { + v { "page finished reveal" } + refreshEmit(false) + injectBackgroundColor() + when { + web.url.isFacebookUrl -> { + web.jsInject( + JsActions.LOGIN_CHECK, + JsAssets.TEXTAREA_LISTENER, + JsAssets.HEADER_BADGES.maybe(isMain), + prefs = prefs + ) + web.facebookJsInject() + } + web.url.isMessengerUrl -> { + web.messengerJsInject() + } } + } + + /** + * Helper to format the request and launch it returns true to override the url returns false if we + * are already in an overlaying activity + */ + private fun launchRequest(request: WebResourceRequest): Boolean { + v { "Launching url: ${request.url}" } + return web.requestWebOverlay(request.url.toString()) + } + + private fun launchImage(url: String, text: String? = null, cookie: String? = null): Boolean { + v { "Launching image: $url" } + web.context.launchImageActivity(url, text, cookie) + if (web.canGoBack()) web.goBack() + return true + } } private const val EMIT_THEME = 0b1 diff --git a/app/src/main/kotlin/com/pitchedapps/frost/web/LoginWebView.kt b/app/src/main/kotlin/com/pitchedapps/frost/web/LoginWebView.kt index bcfcc1090..066487a71 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/web/LoginWebView.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/web/LoginWebView.kt @@ -43,100 +43,91 @@ import com.pitchedapps.frost.prefs.Prefs import com.pitchedapps.frost.utils.L import com.pitchedapps.frost.utils.isFacebookUrl import dagger.hilt.android.AndroidEntryPoint +import javax.inject.Inject import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.coroutineScope -import javax.inject.Inject -/** - * Created by Allan Wang on 2017-05-29. - */ +/** Created by Allan Wang on 2017-05-29. */ @AndroidEntryPoint -class LoginWebView @JvmOverloads constructor( - context: Context, - attrs: AttributeSet? = null, - defStyleAttr: Int = 0 -) : WebView(context, attrs, defStyleAttr) { +class LoginWebView +@JvmOverloads +constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0) : + WebView(context, attrs, defStyleAttr) { - @Inject - lateinit var fbCookie: FbCookie + @Inject lateinit var fbCookie: FbCookie - @Inject - lateinit var prefs: Prefs + @Inject lateinit var prefs: Prefs - @Inject - lateinit var themeProvider: ThemeProvider + @Inject lateinit var themeProvider: ThemeProvider - private val completable: CompletableDeferred = CompletableDeferred() - private lateinit var progressCallback: (Int) -> Unit + private val completable: CompletableDeferred = CompletableDeferred() + private lateinit var progressCallback: (Int) -> Unit - @SuppressLint("SetJavaScriptEnabled") - private fun setupWebview() { - settings.javaScriptEnabled = true - settings.userAgentString = USER_AGENT - setLayerType(View.LAYER_TYPE_HARDWARE, null) - webViewClient = LoginClient() - webChromeClient = LoginChromeClient() + @SuppressLint("SetJavaScriptEnabled") + private fun setupWebview() { + settings.javaScriptEnabled = true + settings.userAgentString = USER_AGENT + setLayerType(View.LAYER_TYPE_HARDWARE, null) + webViewClient = LoginClient() + webChromeClient = LoginChromeClient() + } + + suspend fun loadLogin(progressCallback: (Int) -> Unit): CompletableDeferred = + coroutineScope { + this@LoginWebView.progressCallback = progressCallback + L.d { "Begin loading login" } + launchMain { + fbCookie.reset() + setupWebview() + loadUrl(FB_LOGIN_URL) + } + completable } - suspend fun loadLogin(progressCallback: (Int) -> Unit): CompletableDeferred = - coroutineScope { - this@LoginWebView.progressCallback = progressCallback - L.d { "Begin loading login" } - launchMain { - fbCookie.reset() - setupWebview() - loadUrl(FB_LOGIN_URL) - } - completable - } + private inner class LoginClient : BaseWebViewClient() { - private inner class LoginClient : BaseWebViewClient() { - - override fun onPageFinished(view: WebView, url: String?) { - super.onPageFinished(view, url) - val cookie = checkForLogin(url) - if (cookie != null) - completable.complete(cookie) - if (!view.isVisible) view.fadeIn() - } - - fun checkForLogin(url: String?): CookieEntity? { - if (!url.isFacebookUrl) return null - val cookie = CookieManager.getInstance().getCookie(url) ?: return null - L.d { "Checking cookie for login" } - val id = FB_USER_MATCHER.find(cookie)[1]?.toLong() ?: return null - return CookieEntity(id, null, cookie) - } - - override fun onPageCommitVisible(view: WebView, url: String?) { - super.onPageCommitVisible(view, url) - L.d { "Login page commit visible" } - view.setBackgroundColor(Color.TRANSPARENT) - if (url.isFacebookUrl) - view.jsInject( - CssHider.CORE, - themeProvider.injector(ThemeCategory.FACEBOOK), - prefs = prefs - ) - } - - override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest): Boolean { - // For now, we will ignore all attempts to launch external apps during login - if (request.url == null || request.url.scheme == "intent" || request.url.scheme == "android-app") - return true - return super.shouldOverrideUrlLoading(view, request) - } + override fun onPageFinished(view: WebView, url: String?) { + super.onPageFinished(view, url) + val cookie = checkForLogin(url) + if (cookie != null) completable.complete(cookie) + if (!view.isVisible) view.fadeIn() } - private inner class LoginChromeClient : WebChromeClient() { - override fun onConsoleMessage(consoleMessage: ConsoleMessage): Boolean { - L.v { "Login Console ${consoleMessage.lineNumber()}: ${consoleMessage.message()}" } - return true - } - - override fun onProgressChanged(view: WebView, newProgress: Int) { - super.onProgressChanged(view, newProgress) - progressCallback(newProgress) - } + fun checkForLogin(url: String?): CookieEntity? { + if (!url.isFacebookUrl) return null + val cookie = CookieManager.getInstance().getCookie(url) ?: return null + L.d { "Checking cookie for login" } + val id = FB_USER_MATCHER.find(cookie)[1]?.toLong() ?: return null + return CookieEntity(id, null, cookie) } + + override fun onPageCommitVisible(view: WebView, url: String?) { + super.onPageCommitVisible(view, url) + L.d { "Login page commit visible" } + view.setBackgroundColor(Color.TRANSPARENT) + if (url.isFacebookUrl) + view.jsInject(CssHider.CORE, themeProvider.injector(ThemeCategory.FACEBOOK), prefs = prefs) + } + + override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest): Boolean { + // For now, we will ignore all attempts to launch external apps during login + if ( + request.url == null || request.url.scheme == "intent" || request.url.scheme == "android-app" + ) + return true + return super.shouldOverrideUrlLoading(view, request) + } + } + + private inner class LoginChromeClient : WebChromeClient() { + override fun onConsoleMessage(consoleMessage: ConsoleMessage): Boolean { + L.v { "Login Console ${consoleMessage.lineNumber()}: ${consoleMessage.message()}" } + return true + } + + override fun onProgressChanged(view: WebView, newProgress: Int) { + super.onProgressChanged(view, newProgress) + progressCallback(newProgress) + } + } } diff --git a/app/src/main/kotlin/com/pitchedapps/frost/web/NestedWebView.kt b/app/src/main/kotlin/com/pitchedapps/frost/web/NestedWebView.kt index 294c2ac16..adcdd7a28 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/web/NestedWebView.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/web/NestedWebView.kt @@ -30,119 +30,116 @@ import androidx.core.view.ViewCompat * * Webview extension that handles nested scrolls */ -open class NestedWebView( - context: Context, - attrs: AttributeSet?, - defStyleAttr: Int -) : WebView(context, attrs, defStyleAttr), NestedScrollingChild { +open class NestedWebView(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : + WebView(context, attrs, defStyleAttr), NestedScrollingChild { - // No JvmOverloads due to hilt - constructor(context: Context) : this(context, null) + // No JvmOverloads due to hilt + constructor(context: Context) : this(context, null) - constructor(context: Context, attrs: AttributeSet?) : this(context, attrs, 0) + constructor(context: Context, attrs: AttributeSet?) : this(context, attrs, 0) - private lateinit var childHelper: NestedScrollingChildHelper - private var lastY: Int = 0 - private val scrollOffset = IntArray(2) - private val scrollConsumed = IntArray(2) - private var nestedOffsetY: Int = 0 + private lateinit var childHelper: NestedScrollingChildHelper + private var lastY: Int = 0 + private val scrollOffset = IntArray(2) + private val scrollConsumed = IntArray(2) + private var nestedOffsetY: Int = 0 - init { - init() - } + init { + init() + } - fun init() { - // To avoid leaking constructor - childHelper = NestedScrollingChildHelper(this) - } + fun init() { + // To avoid leaking constructor + childHelper = NestedScrollingChildHelper(this) + } - /** - * Handle nested scrolling against SwipeRecyclerView - * Courtesy of takahirom - * - * https://github.com/takahirom/webview-in-coordinatorlayout/blob/master/app/src/main/java/com/github/takahirom/webview_in_coodinator_layout/NestedWebView.java - */ - @SuppressLint("ClickableViewAccessibility") - final override fun onTouchEvent(ev: MotionEvent): Boolean { - val event = MotionEvent.obtain(ev) - val action = event.action - if (action == MotionEvent.ACTION_DOWN) - nestedOffsetY = 0 - val eventY = event.y.toInt() - event.offsetLocation(0f, nestedOffsetY.toFloat()) - val returnValue: Boolean - when (action) { - MotionEvent.ACTION_MOVE -> { - var deltaY = lastY - eventY - // NestedPreScroll - if (dispatchNestedPreScroll(0, deltaY, scrollConsumed, scrollOffset)) { - deltaY -= scrollConsumed[1] - } - lastY = eventY - scrollOffset[1] - returnValue = super.onTouchEvent(event) - // NestedScroll - if (dispatchNestedScroll(0, scrollOffset[1], 0, deltaY, scrollOffset)) { - event.offsetLocation(0f, scrollOffset[1].toFloat()) - nestedOffsetY += scrollOffset[1] - lastY -= scrollOffset[1] - } - } - MotionEvent.ACTION_DOWN -> { - returnValue = super.onTouchEvent(event) - lastY = eventY - startNestedScroll(ViewCompat.SCROLL_AXIS_VERTICAL) - } - MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> { - returnValue = super.onTouchEvent(event) - stopNestedScroll() - } - else -> return false + /** + * Handle nested scrolling against SwipeRecyclerView Courtesy of takahirom + * + * https://github.com/takahirom/webview-in-coordinatorlayout/blob/master/app/src/main/java/com/github/takahirom/webview_in_coodinator_layout/NestedWebView.java + */ + @SuppressLint("ClickableViewAccessibility") + final override fun onTouchEvent(ev: MotionEvent): Boolean { + val event = MotionEvent.obtain(ev) + val action = event.action + if (action == MotionEvent.ACTION_DOWN) nestedOffsetY = 0 + val eventY = event.y.toInt() + event.offsetLocation(0f, nestedOffsetY.toFloat()) + val returnValue: Boolean + when (action) { + MotionEvent.ACTION_MOVE -> { + var deltaY = lastY - eventY + // NestedPreScroll + if (dispatchNestedPreScroll(0, deltaY, scrollConsumed, scrollOffset)) { + deltaY -= scrollConsumed[1] } - return returnValue + lastY = eventY - scrollOffset[1] + returnValue = super.onTouchEvent(event) + // NestedScroll + if (dispatchNestedScroll(0, scrollOffset[1], 0, deltaY, scrollOffset)) { + event.offsetLocation(0f, scrollOffset[1].toFloat()) + nestedOffsetY += scrollOffset[1] + lastY -= scrollOffset[1] + } + } + MotionEvent.ACTION_DOWN -> { + returnValue = super.onTouchEvent(event) + lastY = eventY + startNestedScroll(ViewCompat.SCROLL_AXIS_VERTICAL) + } + MotionEvent.ACTION_UP, + MotionEvent.ACTION_CANCEL -> { + returnValue = super.onTouchEvent(event) + stopNestedScroll() + } + else -> return false } + return returnValue + } - /* - * --------------------------------------------- - * Nested Scrolling Content - * --------------------------------------------- - */ + /* + * --------------------------------------------- + * Nested Scrolling Content + * --------------------------------------------- + */ - final override fun setNestedScrollingEnabled(enabled: Boolean) { - childHelper.isNestedScrollingEnabled = enabled - } + final override fun setNestedScrollingEnabled(enabled: Boolean) { + childHelper.isNestedScrollingEnabled = enabled + } - final override fun isNestedScrollingEnabled() = childHelper.isNestedScrollingEnabled + final override fun isNestedScrollingEnabled() = childHelper.isNestedScrollingEnabled - final override fun startNestedScroll(axes: Int) = childHelper.startNestedScroll(axes) + final override fun startNestedScroll(axes: Int) = childHelper.startNestedScroll(axes) - final override fun stopNestedScroll() = childHelper.stopNestedScroll() + final override fun stopNestedScroll() = childHelper.stopNestedScroll() - final override fun hasNestedScrollingParent() = childHelper.hasNestedScrollingParent() + final override fun hasNestedScrollingParent() = childHelper.hasNestedScrollingParent() - final override fun dispatchNestedScroll( - dxConsumed: Int, - dyConsumed: Int, - dxUnconsumed: Int, - dyUnconsumed: Int, - offsetInWindow: IntArray? - ) = childHelper.dispatchNestedScroll( - dxConsumed, - dyConsumed, - dxUnconsumed, - dyUnconsumed, - offsetInWindow + final override fun dispatchNestedScroll( + dxConsumed: Int, + dyConsumed: Int, + dxUnconsumed: Int, + dyUnconsumed: Int, + offsetInWindow: IntArray? + ) = + childHelper.dispatchNestedScroll( + dxConsumed, + dyConsumed, + dxUnconsumed, + dyUnconsumed, + offsetInWindow ) - final override fun dispatchNestedPreScroll( - dx: Int, - dy: Int, - consumed: IntArray?, - offsetInWindow: IntArray? - ) = childHelper.dispatchNestedPreScroll(dx, dy, consumed, offsetInWindow) + final override fun dispatchNestedPreScroll( + dx: Int, + dy: Int, + consumed: IntArray?, + offsetInWindow: IntArray? + ) = childHelper.dispatchNestedPreScroll(dx, dy, consumed, offsetInWindow) - final override fun dispatchNestedFling(velocityX: Float, velocityY: Float, consumed: Boolean) = - childHelper.dispatchNestedFling(velocityX, velocityY, consumed) + final override fun dispatchNestedFling(velocityX: Float, velocityY: Float, consumed: Boolean) = + childHelper.dispatchNestedFling(velocityX, velocityY, consumed) - final override fun dispatchNestedPreFling(velocityX: Float, velocityY: Float) = - childHelper.dispatchNestedPreFling(velocityX, velocityY) + final override fun dispatchNestedPreFling(velocityX: Float, velocityY: Float) = + childHelper.dispatchNestedPreFling(velocityX, velocityY) } diff --git a/app/src/main/kotlin/com/pitchedapps/frost/widgets/NotificationWidget.kt b/app/src/main/kotlin/com/pitchedapps/frost/widgets/NotificationWidget.kt index d610a5352..fc088e6a9 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/widgets/NotificationWidget.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/widgets/NotificationWidget.kt @@ -54,181 +54,178 @@ import javax.inject.Inject @AndroidEntryPoint class NotificationWidget : AppWidgetProvider() { - @Inject - lateinit var prefs: Prefs + @Inject lateinit var prefs: Prefs - @Inject - lateinit var themeProvider: ThemeProvider + @Inject lateinit var themeProvider: ThemeProvider - override fun onUpdate( - context: Context, - appWidgetManager: AppWidgetManager, - appWidgetIds: IntArray - ) { - super.onUpdate(context, appWidgetManager, appWidgetIds) - val type = NotificationType.GENERAL - val userId = prefs.userId - val intent = NotificationWidgetService.createIntent(context, type, userId) - for (id in appWidgetIds) { - val views = RemoteViews(context.packageName, R.layout.widget_notifications) + override fun onUpdate( + context: Context, + appWidgetManager: AppWidgetManager, + appWidgetIds: IntArray + ) { + super.onUpdate(context, appWidgetManager, appWidgetIds) + val type = NotificationType.GENERAL + val userId = prefs.userId + val intent = NotificationWidgetService.createIntent(context, type, userId) + for (id in appWidgetIds) { + val views = RemoteViews(context.packageName, R.layout.widget_notifications) - views.setBackgroundColor(R.id.widget_layout_toolbar, themeProvider.headerColor) - views.setIcon(R.id.img_frost, context, R.drawable.frost_f_24, themeProvider.iconColor) - views.setOnClickPendingIntent( - R.id.img_frost, - PendingIntent.getActivity(context, 0, Intent(context, MainActivity::class.java), 0) - ) + views.setBackgroundColor(R.id.widget_layout_toolbar, themeProvider.headerColor) + views.setIcon(R.id.img_frost, context, R.drawable.frost_f_24, themeProvider.iconColor) + views.setOnClickPendingIntent( + R.id.img_frost, + PendingIntent.getActivity(context, 0, Intent(context, MainActivity::class.java), 0) + ) - views.setBackgroundColor(R.id.widget_notification_list, themeProvider.bgColor) - views.setRemoteAdapter(R.id.widget_notification_list, intent) + views.setBackgroundColor(R.id.widget_notification_list, themeProvider.bgColor) + views.setRemoteAdapter(R.id.widget_notification_list, intent) - val pendingIntentTemplate = PendingIntent.getActivity( - context, - 0, - type.createCommonIntent(context, userId), - pendingIntentFlagUpdateCurrent - ) + val pendingIntentTemplate = + PendingIntent.getActivity( + context, + 0, + type.createCommonIntent(context, userId), + pendingIntentFlagUpdateCurrent + ) - views.setPendingIntentTemplate(R.id.widget_notification_list, pendingIntentTemplate) + views.setPendingIntentTemplate(R.id.widget_notification_list, pendingIntentTemplate) - appWidgetManager.updateAppWidget(id, views) - } - appWidgetManager.notifyAppWidgetViewDataChanged(appWidgetIds, R.id.widget_notification_list) + appWidgetManager.updateAppWidget(id, views) } + appWidgetManager.notifyAppWidgetViewDataChanged(appWidgetIds, R.id.widget_notification_list) + } - private val pendingIntentFlagUpdateCurrent: Int - get() = PendingIntent.FLAG_UPDATE_CURRENT or - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) PendingIntent.FLAG_IMMUTABLE else 0 + private val pendingIntentFlagUpdateCurrent: Int + get() = + PendingIntent.FLAG_UPDATE_CURRENT or + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) PendingIntent.FLAG_IMMUTABLE else 0 - companion object { - fun forceUpdate(context: Context) { - val manager = AppWidgetManager.getInstance(context) - val ids = - manager.getAppWidgetIds(ComponentName(context, NotificationWidget::class.java)) - val intent = Intent().apply { - action = AppWidgetManager.ACTION_APPWIDGET_UPDATE - putExtra(AppWidgetManager.EXTRA_APPWIDGET_IDS, ids) - } - context.sendBroadcast(intent) + companion object { + fun forceUpdate(context: Context) { + val manager = AppWidgetManager.getInstance(context) + val ids = manager.getAppWidgetIds(ComponentName(context, NotificationWidget::class.java)) + val intent = + Intent().apply { + action = AppWidgetManager.ACTION_APPWIDGET_UPDATE + putExtra(AppWidgetManager.EXTRA_APPWIDGET_IDS, ids) } + context.sendBroadcast(intent) } + } } private const val NOTIF_WIDGET_TYPE = "notif_widget_type" private const val NOTIF_WIDGET_USER_ID = "notif_widget_user_id" private fun RemoteViews.setBackgroundColor(@IdRes viewId: Int, @ColorInt color: Int) { - setInt(viewId, "setBackgroundColor", color) + setInt(viewId, "setBackgroundColor", color) } -/** - * Adds backward compatibility to setting tinted icons - */ +/** Adds backward compatibility to setting tinted icons */ private fun RemoteViews.setIcon( - @IdRes viewId: Int, - context: Context, - @DrawableRes res: Int, - @ColorInt color: Int + @IdRes viewId: Int, + context: Context, + @DrawableRes res: Int, + @ColorInt color: Int ) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - val icon = - Icon.createWithResource(context, res).setTint(color).setTintMode(PorterDuff.Mode.SRC_IN) - setImageViewIcon(viewId, icon) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val icon = + Icon.createWithResource(context, res).setTint(color).setTintMode(PorterDuff.Mode.SRC_IN) + setImageViewIcon(viewId, icon) + } else { + val bitmap = BitmapFactory.decodeResource(context.resources, res) + if (bitmap != null) { + val paint = Paint() + paint.colorFilter = PorterDuffColorFilter(color, PorterDuff.Mode.SRC_IN) + val result = Bitmap.createBitmap(bitmap.width, bitmap.height, Bitmap.Config.ARGB_8888) + val canvas = Canvas(result) + canvas.drawBitmap(bitmap, 0f, 0f, paint) + setImageViewBitmap(viewId, result) } else { - val bitmap = BitmapFactory.decodeResource(context.resources, res) - if (bitmap != null) { - val paint = Paint() - paint.colorFilter = PorterDuffColorFilter(color, PorterDuff.Mode.SRC_IN) - val result = Bitmap.createBitmap(bitmap.width, bitmap.height, Bitmap.Config.ARGB_8888) - val canvas = Canvas(result) - canvas.drawBitmap(bitmap, 0f, 0f, paint) - setImageViewBitmap(viewId, result) - } else { - // Fallback to just icon - setImageViewResource(viewId, res) - } + // Fallback to just icon + setImageViewResource(viewId, res) } + } } @AndroidEntryPoint class NotificationWidgetService : RemoteViewsService() { - @Inject - lateinit var themeProvider: ThemeProvider + @Inject lateinit var themeProvider: ThemeProvider - @Inject - lateinit var notifDao: NotificationDao + @Inject lateinit var notifDao: NotificationDao - override fun onGetViewFactory(intent: Intent): RemoteViewsFactory = - NotificationWidgetDataProvider(this, intent, themeProvider, notifDao) + override fun onGetViewFactory(intent: Intent): RemoteViewsFactory = + NotificationWidgetDataProvider(this, intent, themeProvider, notifDao) - companion object { - fun createIntent(context: Context, type: NotificationType, userId: Long): Intent = - Intent(context, NotificationWidgetService::class.java) - .putExtra(NOTIF_WIDGET_TYPE, type.name) - .putExtra(NOTIF_WIDGET_USER_ID, userId) - } + companion object { + fun createIntent(context: Context, type: NotificationType, userId: Long): Intent = + Intent(context, NotificationWidgetService::class.java) + .putExtra(NOTIF_WIDGET_TYPE, type.name) + .putExtra(NOTIF_WIDGET_USER_ID, userId) + } } class NotificationWidgetDataProvider( - private val context: Context, - private val intent: Intent, - private val themeProvider: ThemeProvider, - private val notifDao: NotificationDao + private val context: Context, + private val intent: Intent, + private val themeProvider: ThemeProvider, + private val notifDao: NotificationDao ) : RemoteViewsService.RemoteViewsFactory { - @Volatile - private var content: List = emptyList() + @Volatile private var content: List = emptyList() - private val type = NotificationType.valueOf(intent.getStringExtra(NOTIF_WIDGET_TYPE)!!) + private val type = NotificationType.valueOf(intent.getStringExtra(NOTIF_WIDGET_TYPE)!!) - private val userId = intent.getLongExtra(NOTIF_WIDGET_USER_ID, -1) + private val userId = intent.getLongExtra(NOTIF_WIDGET_USER_ID, -1) - private val avatarSize = context.dimenPixelSize(R.dimen.avatar_image_size) + private val avatarSize = context.dimenPixelSize(R.dimen.avatar_image_size) - private val glide = GlideApp.with(context).asBitmap() + private val glide = GlideApp.with(context).asBitmap() - private fun loadNotifications() { - content = notifDao.selectNotificationsSync(userId, type.channelId) + private fun loadNotifications() { + content = notifDao.selectNotificationsSync(userId, type.channelId) + } + + override fun onCreate() {} + + override fun onDataSetChanged() { + loadNotifications() + } + + override fun getLoadingView(): RemoteViews? = null + + override fun getItemId(position: Int): Long = content[position].id + + override fun hasStableIds(): Boolean = true + + override fun getViewAt(position: Int): RemoteViews { + val views = RemoteViews(context.packageName, R.layout.widget_notification_item) + try { + val notif = content[position] + views.setBackgroundColor(R.id.item_frame, themeProvider.nativeBgColor(notif.unread)) + views.setTextColor(R.id.item_content, themeProvider.textColor) + views.setTextViewText(R.id.item_content, notif.text) + views.setTextColor(R.id.item_date, themeProvider.textColor.withAlpha(150)) + views.setTextViewText(R.id.item_date, notif.timestamp.toReadableTime(context)) + + val avatar = + glide + .load(notif.profileUrl) + .transform(FrostGlide.circleCrop) + .submit(avatarSize, avatarSize) + .get() + views.setImageViewBitmap(R.id.item_avatar, avatar) + views.setOnClickFillInIntent(R.id.item_frame, type.putContentExtra(Intent(), notif)) + } catch (_: IndexOutOfBoundsException) { + // Ignored; seems like an Android bug } + return views + } - override fun onCreate() { - } + override fun getCount(): Int = content.size - override fun onDataSetChanged() { - loadNotifications() - } + override fun getViewTypeCount(): Int = 1 - override fun getLoadingView(): RemoteViews? = null - - override fun getItemId(position: Int): Long = content[position].id - - override fun hasStableIds(): Boolean = true - - override fun getViewAt(position: Int): RemoteViews { - val views = RemoteViews(context.packageName, R.layout.widget_notification_item) - try { - val notif = content[position] - views.setBackgroundColor(R.id.item_frame, themeProvider.nativeBgColor(notif.unread)) - views.setTextColor(R.id.item_content, themeProvider.textColor) - views.setTextViewText(R.id.item_content, notif.text) - views.setTextColor(R.id.item_date, themeProvider.textColor.withAlpha(150)) - views.setTextViewText(R.id.item_date, notif.timestamp.toReadableTime(context)) - - val avatar = glide.load(notif.profileUrl).transform(FrostGlide.circleCrop) - .submit(avatarSize, avatarSize).get() - views.setImageViewBitmap(R.id.item_avatar, avatar) - views.setOnClickFillInIntent(R.id.item_frame, type.putContentExtra(Intent(), notif)) - } catch (_: IndexOutOfBoundsException) { - // Ignored; seems like an Android bug - } - return views - } - - override fun getCount(): Int = content.size - - override fun getViewTypeCount(): Int = 1 - - override fun onDestroy() { - } + override fun onDestroy() {} } diff --git a/app/src/test/kotlin/com/pitchedapps/frost/debugger/OfflineWebsiteTest.kt b/app/src/test/kotlin/com/pitchedapps/frost/debugger/OfflineWebsiteTest.kt index 48587ea97..62cbd3aa9 100644 --- a/app/src/test/kotlin/com/pitchedapps/frost/debugger/OfflineWebsiteTest.kt +++ b/app/src/test/kotlin/com/pitchedapps/frost/debugger/OfflineWebsiteTest.kt @@ -18,12 +18,6 @@ package com.pitchedapps.frost.debugger import com.pitchedapps.frost.facebook.FB_URL_BASE import com.pitchedapps.frost.internal.COOKIE -import kotlinx.coroutines.runBlocking -import okhttp3.mockwebserver.Dispatcher -import okhttp3.mockwebserver.MockResponse -import okhttp3.mockwebserver.MockWebServer -import okhttp3.mockwebserver.RecordedRequest -import org.junit.Assume.assumeTrue import java.io.File import java.util.zip.ZipFile import kotlin.test.AfterTest @@ -33,69 +27,70 @@ import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertNotNull import kotlin.test.assertTrue +import kotlinx.coroutines.runBlocking +import okhttp3.mockwebserver.Dispatcher +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import okhttp3.mockwebserver.RecordedRequest +import org.junit.Assume.assumeTrue -/** - * Created by Allan Wang on 05/01/18. - */ +/** Created by Allan Wang on 05/01/18. */ class OfflineWebsiteTest { - lateinit var server: MockWebServer - lateinit var baseDir: File + lateinit var server: MockWebServer + lateinit var baseDir: File - @BeforeTest - fun before() { - val buildPath = - if (File("").absoluteFile.name == "app") "build/offline_test" else "app/build/offline_test" - val rootDir = File(buildPath) - rootDir.deleteRecursively() - baseDir = rootDir.resolve(System.currentTimeMillis().toString()) - server = MockWebServer() - server.start() + @BeforeTest + fun before() { + val buildPath = + if (File("").absoluteFile.name == "app") "build/offline_test" else "app/build/offline_test" + val rootDir = File(buildPath) + rootDir.deleteRecursively() + baseDir = rootDir.resolve(System.currentTimeMillis().toString()) + server = MockWebServer() + server.start() + } + + @AfterTest + fun after() { + server.shutdown() + } + + private fun zipAndFetch(url: String = server.url("/").toString(), cookie: String = ""): ZipFile { + val name = "test" + runBlocking { + val success = OfflineWebsite(url, cookie, baseDir = baseDir).loadAndZip(name) + assertTrue(success, "An error occurred") } - @AfterTest - fun after() { - server.shutdown() - } + return ZipFile(File(baseDir, "$name.zip")) + } - private fun zipAndFetch( - url: String = server.url("/").toString(), - cookie: String = "" - ): ZipFile { - val name = "test" - runBlocking { - val success = OfflineWebsite(url, cookie, baseDir = baseDir) - .loadAndZip(name) - assertTrue(success, "An error occurred") - } + private val tagWhitespaceRegex = Regex(">\\s+<", setOf(RegexOption.MULTILINE)) - return ZipFile(File(baseDir, "$name.zip")) - } + private fun ZipFile.assertContentEquals(path: String, content: String) { + val entry = getEntry(path) + assertNotNull(entry, "Entry $path not found") + val actualContent = getInputStream(entry).bufferedReader().use { it.readText() } + assertEquals( + content.replace(tagWhitespaceRegex, "><").toLowerCase(), + actualContent.replace(tagWhitespaceRegex, "><").toLowerCase(), + "Content mismatch for $path" + ) + } - private val tagWhitespaceRegex = Regex(">\\s+<", setOf(RegexOption.MULTILINE)) + @Ignore("Not really a test") + @Test + fun fbOffline() { + // Not really a test. Skip in CI + assumeTrue(COOKIE.isNotEmpty()) + zipAndFetch(FB_URL_BASE) + } - private fun ZipFile.assertContentEquals(path: String, content: String) { - val entry = getEntry(path) - assertNotNull(entry, "Entry $path not found") - val actualContent = getInputStream(entry).bufferedReader().use { it.readText() } - assertEquals( - content.replace(tagWhitespaceRegex, "><").toLowerCase(), - actualContent.replace(tagWhitespaceRegex, "><").toLowerCase(), - "Content mismatch for $path" - ) - } - - @Ignore("Not really a test") - @Test - fun fbOffline() { - // Not really a test. Skip in CI - assumeTrue(COOKIE.isNotEmpty()) - zipAndFetch(FB_URL_BASE) - } - - @Test - fun basicSingleFile() { - val content = """ + @Test + fun basicSingleFile() { + val content = + """ @@ -103,21 +98,23 @@ class OfflineWebsiteTest {

Single File Test

- """.trimIndent() + """.trimIndent( + ) - server.enqueue(MockResponse().setBody(content)) + server.enqueue(MockResponse().setBody(content)) - val zip = zipAndFetch() + val zip = zipAndFetch() - assertEquals(1, zip.size(), "1 file expected") - zip.assertContentEquals("index.html", content) - } + assertEquals(1, zip.size(), "1 file expected") + zip.assertContentEquals("index.html", content) + } - @Test - fun withCssAsset() { - val cssUrl = server.url("1.css") + @Test + fun withCssAsset() { + val cssUrl = server.url("1.css") - val content = """ + val content = + """ @@ -127,36 +124,38 @@ class OfflineWebsiteTest {

Css File Test

- """.trimIndent() + """.trimIndent( + ) - val css1 = """ + val css1 = + """ .hello { display: none; } """.trimIndent() - server.dispatcher = object : Dispatcher() { - override fun dispatch(request: RecordedRequest): MockResponse = - when { - request.path?.contains(cssUrl.encodedPath) == true -> MockResponse().setBody( - css1 - ) - else -> MockResponse().setBody(content) - } - } + server.dispatcher = + object : Dispatcher() { + override fun dispatch(request: RecordedRequest): MockResponse = + when { + request.path?.contains(cssUrl.encodedPath) == true -> MockResponse().setBody(css1) + else -> MockResponse().setBody(content) + } + } - val zip = zipAndFetch() + val zip = zipAndFetch() - assertEquals(2, zip.size(), "2 files expected") - zip.assertContentEquals("index.html", content.replace(cssUrl.toString(), "assets/a0_1.css")) - zip.assertContentEquals("assets/a0_1.css", css1) - } + assertEquals(2, zip.size(), "2 files expected") + zip.assertContentEquals("index.html", content.replace(cssUrl.toString(), "assets/a0_1.css")) + zip.assertContentEquals("assets/a0_1.css", css1) + } - @Test - fun withJsAsset() { - val jsUrl = server.url("1.js") + @Test + fun withJsAsset() { + val jsUrl = server.url("1.js") - val content = """ + val content = + """ @@ -165,38 +164,38 @@ class OfflineWebsiteTest { - """.trimIndent() + """.trimIndent( + ) - val js1 = """ + val js1 = """ console.log('hello'); """.trimIndent() - server.dispatcher = object : Dispatcher() { - override fun dispatch(request: RecordedRequest): MockResponse = - when { - request.path?.contains(jsUrl.encodedPath) == true -> MockResponse().setBody(js1) - else -> MockResponse().setBody(content) - } - } + server.dispatcher = + object : Dispatcher() { + override fun dispatch(request: RecordedRequest): MockResponse = + when { + request.path?.contains(jsUrl.encodedPath) == true -> MockResponse().setBody(js1) + else -> MockResponse().setBody(content) + } + } - val zip = zipAndFetch() + val zip = zipAndFetch() - assertEquals(2, zip.size(), "2 files expected") - zip.assertContentEquals( - "index.html", - content.replace(jsUrl.toString(), "assets/a0_1.js.txt") - ) - zip.assertContentEquals("assets/a0_1.js.txt", js1) - } + assertEquals(2, zip.size(), "2 files expected") + zip.assertContentEquals("index.html", content.replace(jsUrl.toString(), "assets/a0_1.js.txt")) + zip.assertContentEquals("assets/a0_1.js.txt", js1) + } - @Test - fun fullTest() { - val css1Url = server.url("1.css") - val css2Url = server.url("2.css") - val js1Url = server.url("1.js") - val js2Url = server.url("2.js") + @Test + fun fullTest() { + val css1Url = server.url("1.css") + val css2Url = server.url("2.css") + val js1Url = server.url("1.js") + val js2Url = server.url("2.js") - val content = """ + val content = + """ @@ -209,56 +208,60 @@ class OfflineWebsiteTest { - """.trimIndent() + """.trimIndent( + ) - val css1 = """ + val css1 = + """ .hello { display: none; } """.trimIndent() - val css2 = """ + val css2 = + """ .world { display: none; } """.trimIndent() - val js1 = """ + val js1 = """ console.log('hello'); """.trimIndent() - val js2 = """ + val js2 = """ console.log('world'); """.trimIndent() - server.dispatcher = object : Dispatcher() { - override fun dispatch(request: RecordedRequest): MockResponse { - val path = request.path ?: return MockResponse().setBody(content) - return when { - path.contains(css1Url.encodedPath) -> MockResponse().setBody(css1) - path.contains(css2Url.encodedPath) -> MockResponse().setBody(css2) - path.contains(js1Url.encodedPath) -> MockResponse().setBody(js1) - path.contains(js2Url.encodedPath) -> MockResponse().setBody(js2) - else -> MockResponse().setBody(content) - } - } + server.dispatcher = + object : Dispatcher() { + override fun dispatch(request: RecordedRequest): MockResponse { + val path = request.path ?: return MockResponse().setBody(content) + return when { + path.contains(css1Url.encodedPath) -> MockResponse().setBody(css1) + path.contains(css2Url.encodedPath) -> MockResponse().setBody(css2) + path.contains(js1Url.encodedPath) -> MockResponse().setBody(js1) + path.contains(js2Url.encodedPath) -> MockResponse().setBody(js2) + else -> MockResponse().setBody(content) + } } + } - val zip = zipAndFetch() + val zip = zipAndFetch() - assertEquals(5, zip.size(), "2 files expected") - zip.assertContentEquals( - "index.html", - content - .replace(css1Url.toString(), "assets/a0_1.css") - .replace(css2Url.toString(), "assets/a1_2.css") - .replace(js1Url.toString(), "assets/a2_1.js.txt") - .replace(js2Url.toString(), "assets/a3_2.js.txt") - ) + assertEquals(5, zip.size(), "2 files expected") + zip.assertContentEquals( + "index.html", + content + .replace(css1Url.toString(), "assets/a0_1.css") + .replace(css2Url.toString(), "assets/a1_2.css") + .replace(js1Url.toString(), "assets/a2_1.js.txt") + .replace(js2Url.toString(), "assets/a3_2.js.txt") + ) - zip.assertContentEquals("assets/a0_1.css", css1) - zip.assertContentEquals("assets/a1_2.css", css2) - zip.assertContentEquals("assets/a2_1.js.txt", js1) - zip.assertContentEquals("assets/a3_2.js.txt", js2) - } + zip.assertContentEquals("assets/a0_1.css", css1) + zip.assertContentEquals("assets/a1_2.css", css2) + zip.assertContentEquals("assets/a2_1.js.txt", js1) + zip.assertContentEquals("assets/a3_2.js.txt", js2) + } } diff --git a/app/src/test/kotlin/com/pitchedapps/frost/facebook/FbConstTest.kt b/app/src/test/kotlin/com/pitchedapps/frost/facebook/FbConstTest.kt index 83bce9738..6d6782f30 100644 --- a/app/src/test/kotlin/com/pitchedapps/frost/facebook/FbConstTest.kt +++ b/app/src/test/kotlin/com/pitchedapps/frost/facebook/FbConstTest.kt @@ -21,33 +21,29 @@ import kotlin.test.assertFalse class FbConstTest { - private val constants = listOf( - FACEBOOK_COM, - MESSENGER_COM, - FBCDN_NET, - WWW_FACEBOOK_COM, - WWW_MESSENGER_COM, - HTTPS_FACEBOOK_COM, - HTTPS_MESSENGER_COM, - FACEBOOK_BASE_COM, - FB_URL_BASE, - FACEBOOK_MBASIC_COM, - FB_URL_MBASIC_BASE, - FB_LOGIN_URL, - FB_HOME_URL, - MESSENGER_THREAD_PREFIX + private val constants = + listOf( + FACEBOOK_COM, + MESSENGER_COM, + FBCDN_NET, + WWW_FACEBOOK_COM, + WWW_MESSENGER_COM, + HTTPS_FACEBOOK_COM, + HTTPS_MESSENGER_COM, + FACEBOOK_BASE_COM, + FB_URL_BASE, + FACEBOOK_MBASIC_COM, + FB_URL_MBASIC_BASE, + FB_LOGIN_URL, + FB_HOME_URL, + MESSENGER_THREAD_PREFIX ) - /** - * Make sure we don't have accidental double forward slashes after appending - */ - @Test - fun doubleForwardSlashTest() { - constants.forEach { - assertFalse( - it.replace("https://", "").contains("//"), - "Accidental forward slash for $it" - ) - } + /** Make sure we don't have accidental double forward slashes after appending */ + @Test + fun doubleForwardSlashTest() { + constants.forEach { + assertFalse(it.replace("https://", "").contains("//"), "Accidental forward slash for $it") } + } } diff --git a/app/src/test/kotlin/com/pitchedapps/frost/facebook/FbDomTest.kt b/app/src/test/kotlin/com/pitchedapps/frost/facebook/FbDomTest.kt index 9472adfe1..948247eac 100644 --- a/app/src/test/kotlin/com/pitchedapps/frost/facebook/FbDomTest.kt +++ b/app/src/test/kotlin/com/pitchedapps/frost/facebook/FbDomTest.kt @@ -18,24 +18,24 @@ package com.pitchedapps.frost.facebook import com.pitchedapps.frost.internal.authDependent import com.pitchedapps.frost.internal.testJsoup +import kotlin.test.assertNotNull import org.junit.BeforeClass import org.junit.Test -import kotlin.test.assertNotNull class FbDomTest { - companion object { - @BeforeClass - @JvmStatic - fun before() { - authDependent() - } + companion object { + @BeforeClass + @JvmStatic + fun before() { + authDependent() } + } - @Test - fun checkHeaders() { - val doc = testJsoup(FB_URL_BASE) - assertNotNull(doc.getElementById("header")) - assertNotNull(doc.getElementById("mJewelNav")) - } + @Test + fun checkHeaders() { + val doc = testJsoup(FB_URL_BASE) + assertNotNull(doc.getElementById("header")) + assertNotNull(doc.getElementById("mJewelNav")) + } } diff --git a/app/src/test/kotlin/com/pitchedapps/frost/facebook/FbRegexTest.kt b/app/src/test/kotlin/com/pitchedapps/frost/facebook/FbRegexTest.kt index d37e55c16..d0831687d 100644 --- a/app/src/test/kotlin/com/pitchedapps/frost/facebook/FbRegexTest.kt +++ b/app/src/test/kotlin/com/pitchedapps/frost/facebook/FbRegexTest.kt @@ -16,75 +16,62 @@ */ package com.pitchedapps.frost.facebook +import kotlin.test.assertEquals import org.apache.commons.text.StringEscapeUtils import org.junit.Test -import kotlin.test.assertEquals -/** - * Created by Allan Wang on 24/12/17. - */ +/** Created by Allan Wang on 24/12/17. */ class FbRegexTest { - @Test - fun userIdRegex() { - val id = 12349876L - val cookie = - "wd=1366x615; c_user=$id; act=1234%2F12; m_pixel_ratio=1; presence=hello; x-referer=asdfasdf" - assertEquals(id, FB_USER_MATCHER.find(cookie)[1]?.toLong()) - } + @Test + fun userIdRegex() { + val id = 12349876L + val cookie = + "wd=1366x615; c_user=$id; act=1234%2F12; m_pixel_ratio=1; presence=hello; x-referer=asdfasdf" + assertEquals(id, FB_USER_MATCHER.find(cookie)[1]?.toLong()) + } - @Test - fun fbDtsgRegex() { - val fb_dtsg = "readme" - val input = - "data-sigil=\"mbasic_inline_feed_composer\">\u003Cinput type=\"hidden\" name=\"fb_dtsg\" value=\"$fb_dtsg\" autocomplete=\"off\" \\/>\u003Cinput type=\"hidden\" name=\"privacyx\" value=\"12345\"" - assertEquals(fb_dtsg, FB_DTSG_MATCHER.find(input)[1]) - } + @Test + fun fbDtsgRegex() { + val fb_dtsg = "readme" + val input = + "data-sigil=\"mbasic_inline_feed_composer\">\u003Cinput type=\"hidden\" name=\"fb_dtsg\" value=\"$fb_dtsg\" autocomplete=\"off\" \\/>\u003Cinput type=\"hidden\" name=\"privacyx\" value=\"12345\"" + assertEquals(fb_dtsg, FB_DTSG_MATCHER.find(input)[1]) + } - @Test - fun ppRegex() { - val img = - "https\\3a //scontent-yyz1-1.xx.fbcdn.net/v/asdf1234.jpg?efg\\3d 333\\26 oh\\3d 77\\26 oe\\3d 444" - val imgUnescaped = StringEscapeUtils.unescapeCsv(img) - val ppStyleSingleQuote = "background:#d8dce6 url('$img') no-repeat center;" - val ppStyleDoubleQuote = "background:#d8dce6 url(\"$img\") no-repeat center;" - val ppStyleNoQuote = "background:#d8dce6 url($img) no-repeat center;" - listOf(ppStyleSingleQuote, ppStyleDoubleQuote, ppStyleNoQuote).forEach { - assertEquals( - imgUnescaped, - StringEscapeUtils.unescapeCsv(FB_CSS_URL_MATCHER.find(it)[1]) - ) - } + @Test + fun ppRegex() { + val img = + "https\\3a //scontent-yyz1-1.xx.fbcdn.net/v/asdf1234.jpg?efg\\3d 333\\26 oh\\3d 77\\26 oe\\3d 444" + val imgUnescaped = StringEscapeUtils.unescapeCsv(img) + val ppStyleSingleQuote = "background:#d8dce6 url('$img') no-repeat center;" + val ppStyleDoubleQuote = "background:#d8dce6 url(\"$img\") no-repeat center;" + val ppStyleNoQuote = "background:#d8dce6 url($img) no-repeat center;" + listOf(ppStyleSingleQuote, ppStyleDoubleQuote, ppStyleNoQuote).forEach { + assertEquals(imgUnescaped, StringEscapeUtils.unescapeCsv(FB_CSS_URL_MATCHER.find(it)[1])) } + } - @Test - fun msgNotifIdRegex() { - val id = 1273491646093428L - val data = "threadlist_row_other_user_fbid_thread_fbid_$id" - assertEquals( - id, - FB_MESSAGE_NOTIF_ID_MATCHER.find(data)[1]?.toLong(), - "thread_fbid mismatch" - ) - val userData = "threadlist_row_other_user_fbid_${id}thread_fbid_" - assertEquals( - id, - FB_MESSAGE_NOTIF_ID_MATCHER.find(userData)[1]?.toLong(), - "user_fbid mismatch" - ) - } + @Test + fun msgNotifIdRegex() { + val id = 1273491646093428L + val data = "threadlist_row_other_user_fbid_thread_fbid_$id" + assertEquals(id, FB_MESSAGE_NOTIF_ID_MATCHER.find(data)[1]?.toLong(), "thread_fbid mismatch") + val userData = "threadlist_row_other_user_fbid_${id}thread_fbid_" + assertEquals(id, FB_MESSAGE_NOTIF_ID_MATCHER.find(userData)[1]?.toLong(), "user_fbid mismatch") + } - @Test - fun jsonUrlRegex() { - val url = "https://www.hello.world" - val data = "\"uri\":\"$url\"}" - assertEquals(url, FB_JSON_URL_MATCHER.find(data)[1]) - } + @Test + fun jsonUrlRegex() { + val url = "https://www.hello.world" + val data = "\"uri\":\"$url\"}" + assertEquals(url, FB_JSON_URL_MATCHER.find(data)[1]) + } - @Test - fun imageIdRegex() { - val id = 123456L - val img = - "https://scontent-yyz1-1.xx.fbcdn.net/v/t31.0-8/fr/cp0/e15/q65/89056_${id}_98239_o.jpg" - assertEquals(id, FB_IMAGE_ID_MATCHER.find(img)[1]?.toLongOrNull()) - } + @Test + fun imageIdRegex() { + val id = 123456L + val img = + "https://scontent-yyz1-1.xx.fbcdn.net/v/t31.0-8/fr/cp0/e15/q65/89056_${id}_98239_o.jpg" + assertEquals(id, FB_IMAGE_ID_MATCHER.find(img)[1]?.toLongOrNull()) + } } diff --git a/app/src/test/kotlin/com/pitchedapps/frost/facebook/FbUrlTest.kt b/app/src/test/kotlin/com/pitchedapps/frost/facebook/FbUrlTest.kt index 558cffa1d..9bdce53e1 100644 --- a/app/src/test/kotlin/com/pitchedapps/frost/facebook/FbUrlTest.kt +++ b/app/src/test/kotlin/com/pitchedapps/frost/facebook/FbUrlTest.kt @@ -18,161 +18,155 @@ package com.pitchedapps.frost.facebook import com.pitchedapps.frost.utils.isImageUrl import com.pitchedapps.frost.utils.isIndirectImageUrl -import org.junit.Test import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertTrue +import org.junit.Test -/** - * Created by Allan Wang on 2017-07-07. - */ +/** Created by Allan Wang on 2017-07-07. */ class FbUrlTest { - @Suppress("NOTHING_TO_INLINE") - inline fun assertFbFormat(expected: String, url: String) { - val fbUrl = FbUrlFormatter(url) - assertEquals( - expected, - fbUrl.toString(), - "FbUrl Mismatch:\n${fbUrl.toLogList().joinToString("\n\t")}" + @Suppress("NOTHING_TO_INLINE") + inline fun assertFbFormat(expected: String, url: String) { + val fbUrl = FbUrlFormatter(url) + assertEquals( + expected, + fbUrl.toString(), + "FbUrl Mismatch:\n${fbUrl.toLogList().joinToString("\n\t")}" + ) + } + + @Test + fun base() { + val url = "${FB_URL_BASE}relative/?asdf=1234&hjkl=7890" + assertFbFormat(url, url) + } + + @Test + fun relative() { + val url = "/relative/?asdf=1234&hjkl=7890" + assertFbFormat("$FB_URL_BASE${url.substring(1)}", url) + } + + @Test + fun discard() { + val prefix = "$FB_URL_BASE?test=1234" + val suffix = "&apple=notorange" + assertFbFormat("$prefix$suffix", "$prefix&ref=hello$suffix") + } + + /** + * Unnecessary wraps should be removed & the query items should be bound properly (first & to ?) + */ + @Test + fun queryConversion() { + val url = "${FB_URL_BASE}l.php?u=https%3A%2F%2Fgoogle.ca&qc=hi" + val expected = "https://google.ca?qc=hi" + assertFbFormat(expected, url) + } + + @Test + fun ampersand() { + val url = + "https://scontent-yyz1-1.xx.fbcdn.net/v/t31.0-8/fr/cp0/e15/q65/123.jpg?_nc_cat=0&efg=asdf" + val formattedUrl = + "https://scontent-yyz1-1.xx.fbcdn.net/v/t31.0-8/fr/cp0/e15/q65/123.jpg?_nc_cat=0&efg=asdf" + assertFbFormat(formattedUrl, url) + } + + @Test + fun valuelessQuery() { + val url = "$FB_URL_BASE?foo" + assertFbFormat(url, url) + } + + @Test + fun fbclid() { + val url = "$FB_URL_BASE?foo&fbclid=abc&bar=bbb" + val formattedUrl = "$FB_URL_BASE?foo&bar=bbb" + assertFbFormat(formattedUrl, url) + } + + @Test + fun fbclidNonFacebook() { + val url = "https://www.google.ca?foo&fbclid=abc&bar=bbb" + val formattedUrl = "https://www.google.ca?foo&bar=bbb" + assertFbFormat(formattedUrl, url) + } + + @Test + fun doubleDash() { + assertFbFormat("${FB_URL_BASE}relative", "$FB_URL_BASE/relative") + } + + @Test + fun video() { + // note that the video numbers have been changed to maintain privacy + val url = + "/video_redirect/?src=https%3A%2F%2Fvideo-yyz1-1.xx.fbcdn.net%2Fv%2Ft42.1790-2%2F2349078999904_n.mp4%3Fefg%3DeyJ87J9%26oh%3Df5777784%26oe%3D56FD4&source=media_collage&id=1735049&refid=8&_ft_=qid.6484464%3Amf_story_key.-43172431214%3Atop_level_post_id.102773&__tn__=FEH-R" + val expected = + "https://video-yyz1-1.xx.fbcdn.net/v/t42.1790-2/2349078999904_n.mp4?efg=eyJ87J9&oh=f5777784&oe=56FD4&source=media_collage&id=1735049&__tn__=FEH-R" + assertFbFormat(expected, url) + } + + @Test + fun image() { + arrayOf( + "https://scontent-yyz1-1.xx.fbcdn.net/v/t1.0-9/fr/cp0/e15/q65/229_546131_836546862_n.jpg?efg=e343J9&oh=d4245b1&oe=5453" + // + // "/photo/view_full_size/?fbid=1523&ref_component=mbasic_photo_permalink&ref_page=%2Fwap%2Fphoto.php&refid=153&_ft_=...", + // + // "#!/photo/view_full_size/?fbid=1523&ref_component=mbasic_photo_permalink&ref_page=%2Fwap%2Fphoto.php&refid=153&_ft_=..." ) - } + .forEach { assertTrue(it.isImageUrl, "Failed to match image for $it") } + } - @Test - fun base() { - val url = "${FB_URL_BASE}relative/?asdf=1234&hjkl=7890" - assertFbFormat(url, url) + @Test + fun indirectImage() { + arrayOf("#!/photo/view_full_size/?fbid=107368839645039").forEach { + assertTrue(it.isIndirectImageUrl, "Failed to match indirect image for $it") } + } - @Test - fun relative() { - val url = "/relative/?asdf=1234&hjkl=7890" - assertFbFormat("$FB_URL_BASE${url.substring(1)}", url) + @Test + fun antiImageRegex() { + arrayOf("http...fbcdn.net...mp4", "/photo/...png", "https://www.google.ca").forEach { + assertFalse(it.isImageUrl, "Should not have matched image for $it") } + } - @Test - fun discard() { - val prefix = "$FB_URL_BASE?test=1234" - val suffix = "&apple=notorange" - assertFbFormat("$prefix$suffix", "$prefix&ref=hello$suffix") - } + @Test + fun viewFullImage() { + val url = + "https://scontent-yyz1-1.xx.fbcdn.net/v/t1.0-9/fr/cp0/e15/q65/asdf_n.jpg?efg=asdf&oh=asdf&oe=asdf" + assertFbFormat(url, "#!$url") + } - /** - * Unnecessary wraps should be removed & the query items should be bound properly (first & to ?) - */ - @Test - fun queryConversion() { - val url = "${FB_URL_BASE}l.php?u=https%3A%2F%2Fgoogle.ca&qc=hi" - val expected = "https://google.ca?qc=hi" - assertFbFormat(expected, url) - } + @Test + fun queryFt() { + val url = "${FB_URL_BASE}sample/photos/a.12346/?source=48&_ft_=xxx" + val expected = "${FB_URL_BASE}sample/photos/a.12346/?source=48" + assertFbFormat(expected, url) + } - @Test - fun ampersand() { - val url = - "https://scontent-yyz1-1.xx.fbcdn.net/v/t31.0-8/fr/cp0/e15/q65/123.jpg?_nc_cat=0&efg=asdf" - val formattedUrl = - "https://scontent-yyz1-1.xx.fbcdn.net/v/t31.0-8/fr/cp0/e15/q65/123.jpg?_nc_cat=0&efg=asdf" - assertFbFormat(formattedUrl, url) - } + @Test + fun queryH() { + val url = "${FB_URL_BASE}sample/?h=asdfasdf" + val expected = "${FB_URL_BASE}sample/" + assertFbFormat(expected, url) + } - @Test - fun valuelessQuery() { - val url = "$FB_URL_BASE?foo" - assertFbFormat(url, url) - } + @Test + fun queryUrlEncode() { + val url = "${ FB_URL_BASE}sample/?q=#foo" + val expected = "${ FB_URL_BASE}sample/?q=%23foo" + assertFbFormat(expected, url) + } - @Test - fun fbclid() { - val url = "$FB_URL_BASE?foo&fbclid=abc&bar=bbb" - val formattedUrl = "$FB_URL_BASE?foo&bar=bbb" - assertFbFormat(formattedUrl, url) - } - - @Test - fun fbclidNonFacebook() { - val url = "https://www.google.ca?foo&fbclid=abc&bar=bbb" - val formattedUrl = "https://www.google.ca?foo&bar=bbb" - assertFbFormat(formattedUrl, url) - } - - @Test - fun doubleDash() { - assertFbFormat("${FB_URL_BASE}relative", "$FB_URL_BASE/relative") - } - - @Test - fun video() { - // note that the video numbers have been changed to maintain privacy - val url = - "/video_redirect/?src=https%3A%2F%2Fvideo-yyz1-1.xx.fbcdn.net%2Fv%2Ft42.1790-2%2F2349078999904_n.mp4%3Fefg%3DeyJ87J9%26oh%3Df5777784%26oe%3D56FD4&source=media_collage&id=1735049&refid=8&_ft_=qid.6484464%3Amf_story_key.-43172431214%3Atop_level_post_id.102773&__tn__=FEH-R" - val expected = - "https://video-yyz1-1.xx.fbcdn.net/v/t42.1790-2/2349078999904_n.mp4?efg=eyJ87J9&oh=f5777784&oe=56FD4&source=media_collage&id=1735049&__tn__=FEH-R" - assertFbFormat(expected, url) - } - - @Test - fun image() { - arrayOf( - "https://scontent-yyz1-1.xx.fbcdn.net/v/t1.0-9/fr/cp0/e15/q65/229_546131_836546862_n.jpg?efg=e343J9&oh=d4245b1&oe=5453" -// "/photo/view_full_size/?fbid=1523&ref_component=mbasic_photo_permalink&ref_page=%2Fwap%2Fphoto.php&refid=153&_ft_=...", -// "#!/photo/view_full_size/?fbid=1523&ref_component=mbasic_photo_permalink&ref_page=%2Fwap%2Fphoto.php&refid=153&_ft_=..." - ).forEach { - assertTrue(it.isImageUrl, "Failed to match image for $it") - } - } - - @Test - fun indirectImage() { - arrayOf( - "#!/photo/view_full_size/?fbid=107368839645039" - ).forEach { - assertTrue(it.isIndirectImageUrl, "Failed to match indirect image for $it") - } - } - - @Test - fun antiImageRegex() { - arrayOf( - "http...fbcdn.net...mp4", - "/photo/...png", - "https://www.google.ca" - ).forEach { - assertFalse(it.isImageUrl, "Should not have matched image for $it") - } - } - - @Test - fun viewFullImage() { - val url = - "https://scontent-yyz1-1.xx.fbcdn.net/v/t1.0-9/fr/cp0/e15/q65/asdf_n.jpg?efg=asdf&oh=asdf&oe=asdf" - assertFbFormat(url, "#!$url") - } - - @Test - fun queryFt() { - val url = "${FB_URL_BASE}sample/photos/a.12346/?source=48&_ft_=xxx" - val expected = "${FB_URL_BASE}sample/photos/a.12346/?source=48" - assertFbFormat(expected, url) - } - - @Test - fun queryH() { - val url = "${FB_URL_BASE}sample/?h=asdfasdf" - val expected = "${FB_URL_BASE}sample/" - assertFbFormat(expected, url) - } - - @Test - fun queryUrlEncode() { - val url = "${ FB_URL_BASE}sample/?q=#foo" - val expected = "${ FB_URL_BASE}sample/?q=%23foo" - assertFbFormat(expected, url) - } - -// @Test -// fun viewFullImageIndirect() { -// val urlBase = "photo/view_full_size/?fbid=1234&ref_component=mbasic_photo_permalink&ref_page=%2Fwap%2Fphoto.php&refid=13&_ft_=qid.1234%3Amf_story_key.1234%3Atop_level_post_id" -// assertFbFormat("$FB_URL_BASE$urlBase", "#!/$urlBase") -// } + // @Test + // fun viewFullImageIndirect() { + // val urlBase = + // "photo/view_full_size/?fbid=1234&ref_component=mbasic_photo_permalink&ref_page=%2Fwap%2Fphoto.php&refid=13&_ft_=qid.1234%3Amf_story_key.1234%3Atop_level_post_id" + // assertFbFormat("$FB_URL_BASE$urlBase", "#!/$urlBase") + // } } diff --git a/app/src/test/kotlin/com/pitchedapps/frost/facebook/parsers/FbParseTest.kt b/app/src/test/kotlin/com/pitchedapps/frost/facebook/parsers/FbParseTest.kt index a5955101b..4d4c87035 100644 --- a/app/src/test/kotlin/com/pitchedapps/frost/facebook/parsers/FbParseTest.kt +++ b/app/src/test/kotlin/com/pitchedapps/frost/facebook/parsers/FbParseTest.kt @@ -20,78 +20,71 @@ import com.pitchedapps.frost.internal.COOKIE import com.pitchedapps.frost.internal.assertComponentsNotEmpty import com.pitchedapps.frost.internal.assertDescending import com.pitchedapps.frost.internal.authDependent -import org.junit.BeforeClass -import org.junit.Test import kotlin.test.assertFalse import kotlin.test.assertNotNull import kotlin.test.assertTrue import kotlin.test.fail +import org.junit.BeforeClass +import org.junit.Test -/** - * Created by Allan Wang on 24/12/17. - */ +/** Created by Allan Wang on 24/12/17. */ class FbParseTest { - companion object { - @BeforeClass - @JvmStatic - fun before() { - authDependent() - } + companion object { + @BeforeClass + @JvmStatic + fun before() { + authDependent() + } + } + + private inline fun FrostParser.test(action: T.() -> Unit = {}) = + parse(COOKIE).test(url, action) + + private inline fun ParseResponse?.test( + url: String, + action: T.() -> Unit = {} + ) { + val response = this ?: fail("${T::class.simpleName} parser returned null for $url") + println(response) + assertFalse(response.data.isEmpty, "${T::class.simpleName} parser returned empty data for $url") + response.data.action() + } + + @Test + fun message() = + MessageParser.test { + threads.forEach { + it.assertComponentsNotEmpty() + assertTrue(it.id > FALLBACK_TIME_MOD, "id may not be properly matched") + assertNotNull(it.img, "img may not be properly matched") + } + threads.map(FrostThread::time).assertDescending("thread time values") } - private inline fun FrostParser.test(action: T.() -> Unit = {}) = - parse(COOKIE).test(url, action) + @Test fun messageUser() = MessageParser.queryUser(COOKIE, "allan").test("allan query") - private inline fun ParseResponse?.test( - url: String, - action: T.() -> Unit = {} - ) { - val response = this - ?: fail("${T::class.simpleName} parser returned null for $url") - println(response) + @Test fun search() = SearchParser.test() + + @Test + fun notif() = + NotifParser.test { + notifs.forEach { + it.assertComponentsNotEmpty() + assertTrue(it.id > FALLBACK_TIME_MOD, "id may not be properly matched") + assertNotNull(it.img, "img may not be properly matched") + } + notifs.map(FrostNotif::time).assertDescending("notif time values") + if (notifs.none { it.unread }) { + println("No messages unread.") + } + notifs.forEach { assertFalse( - response.data.isEmpty, - "${T::class.simpleName} parser returned empty data for $url" + it.content.startsWith("unread", ignoreCase = true), + "Parse error; notif starts with 'Unread'" ) - response.data.action() + } } - @Test - fun message() = MessageParser.test { - threads.forEach { - it.assertComponentsNotEmpty() - assertTrue(it.id > FALLBACK_TIME_MOD, "id may not be properly matched") - assertNotNull(it.img, "img may not be properly matched") - } - threads.map(FrostThread::time).assertDescending("thread time values") - } - - @Test - fun messageUser() = MessageParser.queryUser(COOKIE, "allan").test("allan query") - - @Test - fun search() = SearchParser.test() - - @Test - fun notif() = NotifParser.test { - notifs.forEach { - it.assertComponentsNotEmpty() - assertTrue(it.id > FALLBACK_TIME_MOD, "id may not be properly matched") - assertNotNull(it.img, "img may not be properly matched") - } - notifs.map(FrostNotif::time).assertDescending("notif time values") - if (notifs.none { it.unread }) { - println("No messages unread.") - } - notifs.forEach { - assertFalse( - it.content.startsWith("unread", ignoreCase = true), - "Parse error; notif starts with 'Unread'" - ) - } - } - - @Test - fun badge() = BadgeParser.test() + @Test fun badge() = BadgeParser.test() } diff --git a/app/src/test/kotlin/com/pitchedapps/frost/facebook/requests/FbFullImageTest.kt b/app/src/test/kotlin/com/pitchedapps/frost/facebook/requests/FbFullImageTest.kt index cb8dd5e1e..99a53cfbc 100644 --- a/app/src/test/kotlin/com/pitchedapps/frost/facebook/requests/FbFullImageTest.kt +++ b/app/src/test/kotlin/com/pitchedapps/frost/facebook/requests/FbFullImageTest.kt @@ -18,31 +18,27 @@ package com.pitchedapps.frost.facebook.requests import com.pitchedapps.frost.internal.COOKIE import com.pitchedapps.frost.internal.authDependent +import kotlin.test.assertNotNull import kotlinx.coroutines.runBlocking import org.junit.BeforeClass import org.junit.Test -import kotlin.test.assertNotNull -/** - * Created by Allan Wang on 12/04/18. - */ +/** Created by Allan Wang on 12/04/18. */ class FbFullImageTest { - companion object { - @BeforeClass - @JvmStatic - fun before() { - authDependent() - } + companion object { + @BeforeClass + @JvmStatic + fun before() { + authDependent() } + } - @Test - fun getFullImage() { - val url = "https://touch.facebook.com/photo/view_full_size/?fbid=107368839645039" - val result = runBlocking { - COOKIE.getFullSizedImageUrl(url) - } - assertNotNull(result) - println(result) - } + @Test + fun getFullImage() { + val url = "https://touch.facebook.com/photo/view_full_size/?fbid=107368839645039" + val result = runBlocking { COOKIE.getFullSizedImageUrl(url) } + assertNotNull(result) + println(result) + } } diff --git a/app/src/test/kotlin/com/pitchedapps/frost/injectors/InjectorTest.kt b/app/src/test/kotlin/com/pitchedapps/frost/injectors/InjectorTest.kt index f38f03bb8..ea33bbff2 100644 --- a/app/src/test/kotlin/com/pitchedapps/frost/injectors/InjectorTest.kt +++ b/app/src/test/kotlin/com/pitchedapps/frost/injectors/InjectorTest.kt @@ -25,11 +25,9 @@ import org.junit.Test */ class InjectorTest { - @Test - fun printAll() { - println("CSS Hider Injectors") - CssHider.values().forEach { - println("${it.injector.function}\n") - } - } + @Test + fun printAll() { + println("CSS Hider Injectors") + CssHider.values().forEach { println("${it.injector.function}\n") } + } } diff --git a/app/src/test/kotlin/com/pitchedapps/frost/injectors/JsAssetsTest.kt b/app/src/test/kotlin/com/pitchedapps/frost/injectors/JsAssetsTest.kt index 7fcb8573a..9a731e441 100644 --- a/app/src/test/kotlin/com/pitchedapps/frost/injectors/JsAssetsTest.kt +++ b/app/src/test/kotlin/com/pitchedapps/frost/injectors/JsAssetsTest.kt @@ -22,11 +22,11 @@ import kotlin.test.assertTrue class JsAssetsTest { - @Test - fun verifyAssetsExist() { - JsAssets.values().forEach { asset -> - val file = File("src/web/assets/js/${asset.file}").absoluteFile - assertTrue(file.exists(), "${asset.name} not found at ${file.path}") - } + @Test + fun verifyAssetsExist() { + JsAssets.values().forEach { asset -> + val file = File("src/web/assets/js/${asset.file}").absoluteFile + assertTrue(file.exists(), "${asset.name} not found at ${file.path}") } + } } diff --git a/app/src/test/kotlin/com/pitchedapps/frost/injectors/TagObfuscatorTest.kt b/app/src/test/kotlin/com/pitchedapps/frost/injectors/TagObfuscatorTest.kt index e7145e392..239f555e0 100644 --- a/app/src/test/kotlin/com/pitchedapps/frost/injectors/TagObfuscatorTest.kt +++ b/app/src/test/kotlin/com/pitchedapps/frost/injectors/TagObfuscatorTest.kt @@ -22,17 +22,16 @@ import kotlin.test.assertEquals class TagObfuscatorTest { - /** - * The same key should result in the same tag per session - */ - @Test - fun consistentTags() { - val keys = generateSequence { UUID.randomUUID().toString() }.take(10).toSet() - val tags = keys.map { - val tag = generateSequence { TagObfuscator.obfuscateTag(it) }.take(10).toSet() - assertEquals(1, tag.size, "Key $it produced multiple tags: $tag") - tag.first() - } - assertEquals(keys.size, tags.size, "Key set and tag set have different sizes") - } + /** The same key should result in the same tag per session */ + @Test + fun consistentTags() { + val keys = generateSequence { UUID.randomUUID().toString() }.take(10).toSet() + val tags = + keys.map { + val tag = generateSequence { TagObfuscator.obfuscateTag(it) }.take(10).toSet() + assertEquals(1, tag.size, "Key $it produced multiple tags: $tag") + tag.first() + } + assertEquals(keys.size, tags.size, "Key set and tag set have different sizes") + } } diff --git a/app/src/test/kotlin/com/pitchedapps/frost/injectors/ThemeProviderTest.kt b/app/src/test/kotlin/com/pitchedapps/frost/injectors/ThemeProviderTest.kt index e1ef62258..7635f1671 100644 --- a/app/src/test/kotlin/com/pitchedapps/frost/injectors/ThemeProviderTest.kt +++ b/app/src/test/kotlin/com/pitchedapps/frost/injectors/ThemeProviderTest.kt @@ -24,13 +24,15 @@ import kotlin.test.assertTrue class ThemeProviderTest { - @Test - fun verifyAssetsExist() { - ThemeCategory.values().forEach { category -> - Theme.values.filter { it != Theme.DEFAULT }.forEach { theme -> - val file = File("src/web/assets/css/${category.folder}/themes/${theme.file}").absoluteFile - assertTrue(file.exists(), "${theme.name} not found at ${file.path}") - } + @Test + fun verifyAssetsExist() { + ThemeCategory.values().forEach { category -> + Theme.values + .filter { it != Theme.DEFAULT } + .forEach { theme -> + val file = File("src/web/assets/css/${category.folder}/themes/${theme.file}").absoluteFile + assertTrue(file.exists(), "${theme.name} not found at ${file.path}") } } + } } diff --git a/app/src/test/kotlin/com/pitchedapps/frost/internal/Internal.kt b/app/src/test/kotlin/com/pitchedapps/frost/internal/Internal.kt index 7c12ba736..85ba101d1 100644 --- a/app/src/test/kotlin/com/pitchedapps/frost/internal/Internal.kt +++ b/app/src/test/kotlin/com/pitchedapps/frost/internal/Internal.kt @@ -20,7 +20,6 @@ import com.pitchedapps.frost.facebook.FB_USER_MATCHER import com.pitchedapps.frost.facebook.FbItem import com.pitchedapps.frost.facebook.get import com.pitchedapps.frost.utils.frostJsoup -import org.junit.Assume import java.io.File import java.io.FileInputStream import java.util.Properties @@ -28,66 +27,60 @@ import kotlin.reflect.full.starProjectedType import kotlin.test.assertEquals import kotlin.test.assertTrue import kotlin.test.fail +import org.junit.Assume -/** - * Created by Allan Wang on 21/12/17. - */ - +/** Created by Allan Wang on 21/12/17. */ private const val FILE = "priv.properties" private val propPaths = arrayOf(FILE, "../$FILE") val PROPS: Properties by lazy { - val props = Properties() - val file = propPaths.map(::File).firstOrNull { it.isFile } - if (file == null) { - println("$FILE not found at ${File(".").absolutePath}") - return@lazy props - } - println("Found properties at ${file.absolutePath}") - FileInputStream(file).use { props.load(it) } - props + val props = Properties() + val file = propPaths.map(::File).firstOrNull { it.isFile } + if (file == null) { + println("$FILE not found at ${File(".").absolutePath}") + return@lazy props + } + println("Found properties at ${file.absolutePath}") + FileInputStream(file).use { props.load(it) } + props } val COOKIE: String by lazy { PROPS.getProperty("COOKIE") ?: "" } val USER_ID: Long by lazy { FB_USER_MATCHER.find(COOKIE)[1]?.toLong() ?: -1 } private val VALID_COOKIE: Boolean by lazy { - val data = testJsoup(FbItem.SETTINGS.url) - data.title() == "Settings" + val data = testJsoup(FbItem.SETTINGS.url) + data.title() == "Settings" } fun testJsoup(url: String) = frostJsoup(COOKIE, url) fun authDependent() { - println("Auth Dependent") - Assume.assumeTrue("Cookie cannot be empty", COOKIE.isNotEmpty()) - Assume.assumeTrue("Cookie is not valid", VALID_COOKIE) + println("Auth Dependent") + Assume.assumeTrue("Cookie cannot be empty", COOKIE.isNotEmpty()) + Assume.assumeTrue("Cookie is not valid", VALID_COOKIE) } -/** - * Check that component strings are nonempty and are properly parsed - * To be used for data classes - */ +/** Check that component strings are nonempty and are properly parsed To be used for data classes */ fun Any.assertComponentsNotEmpty() { - val components = this::class.members.filter { it.name.startsWith("component") } - if (components.isEmpty()) - fail("${this::class.simpleName} has no components") - components.forEach { - when (it.returnType) { - String::class.starProjectedType -> { - val result = it.call(this) as String - assertTrue(result.isNotEmpty(), "${it.name} returned empty string") - if (result.startsWith("https")) - assertTrue( - result.startsWith("https://"), - "${it.name} has poorly formatted output $result" - ) - } - } + val components = this::class.members.filter { it.name.startsWith("component") } + if (components.isEmpty()) fail("${this::class.simpleName} has no components") + components.forEach { + when (it.returnType) { + String::class.starProjectedType -> { + val result = it.call(this) as String + assertTrue(result.isNotEmpty(), "${it.name} returned empty string") + if (result.startsWith("https")) + assertTrue( + result.startsWith("https://"), + "${it.name} has poorly formatted output $result" + ) + } } + } } fun > List.assertDescending(tag: String) { - assertEquals(sortedDescending(), this, "$tag not sorted in descending order") + assertEquals(sortedDescending(), this, "$tag not sorted in descending order") } diff --git a/app/src/test/kotlin/com/pitchedapps/frost/prefs/PrefsTest.kt b/app/src/test/kotlin/com/pitchedapps/frost/prefs/PrefsTest.kt index a51dc4600..03456d3d4 100644 --- a/app/src/test/kotlin/com/pitchedapps/frost/prefs/PrefsTest.kt +++ b/app/src/test/kotlin/com/pitchedapps/frost/prefs/PrefsTest.kt @@ -16,42 +16,40 @@ */ package com.pitchedapps.frost.prefs +import kotlin.test.assertEquals import org.junit.Before import org.junit.Test -import kotlin.test.assertEquals -/** - * Created by Allan Wang on 2017-05-31. - */ +/** Created by Allan Wang on 2017-05-31. */ class PrefsTest { - // Replicate logic - var test: Long = -1L - get() { - if (field == -1L) field = file - return field - } - set(value) { - field = value - if (value != -1L) file = value - } - - var file: Long = -1L - - @Before - fun verify() { - test = -1L - file = -1L + // Replicate logic + var test: Long = -1L + get() { + if (field == -1L) field = file + return field + } + set(value) { + field = value + if (value != -1L) file = value } - @Test - fun laziness() { - assertEquals(-1L, test) - file = 2L - assertEquals(2L, test) - file = -3L - assertEquals(2L, test) - test = 3L - assertEquals(3L, file) - } + var file: Long = -1L + + @Before + fun verify() { + test = -1L + file = -1L + } + + @Test + fun laziness() { + assertEquals(-1L, test) + file = 2L + assertEquals(2L, test) + file = -3L + assertEquals(2L, test) + test = 3L + assertEquals(3L, file) + } } diff --git a/app/src/test/kotlin/com/pitchedapps/frost/utils/BuildUtilsTest.kt b/app/src/test/kotlin/com/pitchedapps/frost/utils/BuildUtilsTest.kt index a40372afc..0b39d7d20 100644 --- a/app/src/test/kotlin/com/pitchedapps/frost/utils/BuildUtilsTest.kt +++ b/app/src/test/kotlin/com/pitchedapps/frost/utils/BuildUtilsTest.kt @@ -17,25 +17,25 @@ package com.pitchedapps.frost.utils import com.pitchedapps.frost.BuildConfig -import org.junit.Test import kotlin.test.assertEquals import kotlin.test.assertNotNull import kotlin.test.assertNull +import org.junit.Test class BuildUtilsTest { - @Test - fun matchingVersions() { - assertNull(BuildUtils.match("unknown")) - assertEquals(BuildUtils.Data("v1.0.0", ""), BuildUtils.match("1.0.0")) - assertEquals( - BuildUtils.Data("v2.0.1", "26-af40533-debug"), - BuildUtils.match("2.0.1-26-af40533-debug") - ) - } + @Test + fun matchingVersions() { + assertNull(BuildUtils.match("unknown")) + assertEquals(BuildUtils.Data("v1.0.0", ""), BuildUtils.match("1.0.0")) + assertEquals( + BuildUtils.Data("v2.0.1", "26-af40533-debug"), + BuildUtils.match("2.0.1-26-af40533-debug") + ) + } - @Test - fun androidTests() { - assertNotNull(BuildUtils.match(BuildConfig.VERSION_NAME)) - } + @Test + fun androidTests() { + assertNotNull(BuildUtils.match(BuildConfig.VERSION_NAME)) + } } diff --git a/app/src/test/kotlin/com/pitchedapps/frost/utils/CoroutineTest.kt b/app/src/test/kotlin/com/pitchedapps/frost/utils/CoroutineTest.kt index 7acb4761e..c1278d293 100644 --- a/app/src/test/kotlin/com/pitchedapps/frost/utils/CoroutineTest.kt +++ b/app/src/test/kotlin/com/pitchedapps/frost/utils/CoroutineTest.kt @@ -16,6 +16,12 @@ */ package com.pitchedapps.frost.utils +import java.util.concurrent.Executors +import kotlin.coroutines.EmptyCoroutineContext +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.asCoroutineDispatcher import kotlinx.coroutines.async @@ -29,51 +35,37 @@ import kotlinx.coroutines.flow.takeWhile import kotlinx.coroutines.joinAll import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking -import java.util.concurrent.Executors -import kotlin.coroutines.EmptyCoroutineContext -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertFalse -import kotlin.test.assertTrue -/** - * Collection of tests around coroutines - */ +/** Collection of tests around coroutines */ class CoroutineTest { - private fun SharedFlow.takeUntilNull(): Flow = - takeWhile { it != null }.filterNotNull() + private fun SharedFlow.takeUntilNull(): Flow = + takeWhile { it != null }.filterNotNull() - /** - * Sanity check to ensure that contexts are being honoured - */ - @Test - fun contextSwitching() { - val mainTag = "main-test" - val mainDispatcher = Executors.newSingleThreadExecutor { r -> - Thread(r, mainTag) - }.asCoroutineDispatcher() + /** Sanity check to ensure that contexts are being honoured */ + @Test + fun contextSwitching() { + val mainTag = "main-test" + val mainDispatcher = + Executors.newSingleThreadExecutor { r -> Thread(r, mainTag) }.asCoroutineDispatcher() - val flow = MutableSharedFlow(100) - runBlocking(Dispatchers.IO) { - launch(mainDispatcher) { - flow.takeUntilNull().collect { thread -> - assertTrue( - Thread.currentThread().name.startsWith(mainTag), - "Channel should be received in main thread" - ) - assertFalse( - thread.startsWith(mainTag), - "Channel execution should not be in main thread" - ) - } - } - listOf(EmptyCoroutineContext, Dispatchers.IO, Dispatchers.Default, Dispatchers.IO).map { - async(it) { flow.emit(Thread.currentThread().name) } - }.joinAll() - flow.emit(null) - val count = flow.takeUntilNull().count() - assertEquals(4, count, "Not all events received") + val flow = MutableSharedFlow(100) + runBlocking(Dispatchers.IO) { + launch(mainDispatcher) { + flow.takeUntilNull().collect { thread -> + assertTrue( + Thread.currentThread().name.startsWith(mainTag), + "Channel should be received in main thread" + ) + assertFalse(thread.startsWith(mainTag), "Channel execution should not be in main thread") } + } + listOf(EmptyCoroutineContext, Dispatchers.IO, Dispatchers.Default, Dispatchers.IO) + .map { async(it) { flow.emit(Thread.currentThread().name) } } + .joinAll() + flow.emit(null) + val count = flow.takeUntilNull().count() + assertEquals(4, count, "Not all events received") } + } } diff --git a/app/src/test/kotlin/com/pitchedapps/frost/utils/JsoupCleanerTest.kt b/app/src/test/kotlin/com/pitchedapps/frost/utils/JsoupCleanerTest.kt index 7ec01e6d4..ef3ccb39a 100644 --- a/app/src/test/kotlin/com/pitchedapps/frost/utils/JsoupCleanerTest.kt +++ b/app/src/test/kotlin/com/pitchedapps/frost/utils/JsoupCleanerTest.kt @@ -16,58 +16,58 @@ */ package com.pitchedapps.frost.utils -import org.junit.Test import kotlin.test.assertEquals +import org.junit.Test -/** - * Created by Allan Wang on 2017-08-10. - */ +/** Created by Allan Wang on 2017-08-10. */ class JsoupCleanerTest { - val whitespaceRegex = Regex("\\s+") + val whitespaceRegex = Regex("\\s+") - fun String.cleanWhitespace() = - replace("\n", "").replace(whitespaceRegex, " ").replace("> <", "><") + fun String.cleanWhitespace() = + replace("\n", "").replace(whitespaceRegex, " ").replace("> <", "><") - private fun String.assertCleanHtml(expected: String) { - assertEquals(expected.cleanWhitespace(), cleanHtml().cleanWhitespace()) - } + private fun String.assertCleanHtml(expected: String) { + assertEquals(expected.cleanWhitespace(), cleanHtml().cleanWhitespace()) + } - private fun String.assertCleanJsoup(expected: String) { - assertEquals(expected.cleanWhitespace(), cleanJsoup().cleanWhitespace()) - } + private fun String.assertCleanJsoup(expected: String) { + assertEquals(expected.cleanWhitespace(), cleanJsoup().cleanWhitespace()) + } - private fun String.assertCleanText(expected: String) { - assertEquals(expected.cleanWhitespace(), cleanText().cleanWhitespace()) - } + private fun String.assertCleanText(expected: String) { + assertEquals(expected.cleanWhitespace(), cleanText().cleanWhitespace()) + } - @Test - fun noChange() { - " HI ".assertCleanJsoup(" HI ") - } + @Test + fun noChange() { + " HI ".assertCleanJsoup(" HI ") + } - @Test - fun basicText() { - """
Hello world
""".assertCleanHtml("""
""") - } + @Test + fun basicText() { + """
Hello world
""".assertCleanHtml("""
""") + } - @Test - fun multiLineText() { - """
Hello - world
""".assertCleanHtml("""
""") - } + @Test + fun multiLineText() { + """
Hello + world
""".assertCleanHtml( + """
""" + ) + } - @Test - fun textRemoval() { - """
HelloWorld
""".assertCleanText("
") - } + @Test + fun textRemoval() { + """
HelloWorld
""".assertCleanText("
") + } - @Test - fun kau() { - val html = - """
KAU

An extensive collection of Kotlin Android Utils

KAUclose
  • Huge package of one line extension functions
  • Custom UI views
  • Adapter items and animators
  • SearchView
  • Custom delegates
""" - val expected = - """
""" - html.assertCleanHtml(expected) - } + @Test + fun kau() { + val html = + """
KAU

An extensive collection of Kotlin Android Utils

KAUclose
  • Huge package of one line extension functions
  • Custom UI views
  • Adapter items and animators
  • SearchView
  • Custom delegates
""" + val expected = + """
""" + html.assertCleanHtml(expected) + } } diff --git a/app/src/test/kotlin/com/pitchedapps/frost/utils/StringEscapeUtilsTest.kt b/app/src/test/kotlin/com/pitchedapps/frost/utils/StringEscapeUtilsTest.kt index f5ea56aed..7d5ca2eea 100644 --- a/app/src/test/kotlin/com/pitchedapps/frost/utils/StringEscapeUtilsTest.kt +++ b/app/src/test/kotlin/com/pitchedapps/frost/utils/StringEscapeUtilsTest.kt @@ -16,17 +16,15 @@ */ package com.pitchedapps.frost.utils -import org.junit.Test import kotlin.test.assertEquals +import org.junit.Test -/** - * Created by Allan Wang on 11/03/18. - */ +/** Created by Allan Wang on 11/03/18. */ class StringEscapeUtilsTest { - @Test - fun utf() { - val escaped = "\\u003Chead> color=\\\"#3b5998\\\"" - assertEquals(" color=\"#3b5998\"", escaped.unescapeHtml()) - } + @Test + fun utf() { + val escaped = "\\u003Chead> color=\\\"#3b5998\\\"" + assertEquals(" color=\"#3b5998\"", escaped.unescapeHtml()) + } } diff --git a/app/src/test/kotlin/com/pitchedapps/frost/utils/UrlTests.kt b/app/src/test/kotlin/com/pitchedapps/frost/utils/UrlTests.kt index 485d0a7ab..aac5bcd22 100644 --- a/app/src/test/kotlin/com/pitchedapps/frost/utils/UrlTests.kt +++ b/app/src/test/kotlin/com/pitchedapps/frost/utils/UrlTests.kt @@ -17,48 +17,48 @@ package com.pitchedapps.frost.utils import com.pitchedapps.frost.facebook.FACEBOOK_COM -import org.junit.Test import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertTrue +import org.junit.Test -/** - * Created by Allan Wang on 2017-11-15. - */ +/** Created by Allan Wang on 2017-11-15. */ class UrlTests { - val GOOGLE = "https://www.google.ca" + val GOOGLE = "https://www.google.ca" - @Test - fun independence() { + @Test + fun independence() { - mapOf( - GOOGLE to true, - FACEBOOK_COM to true, - "#!/photos/viewer/?photoset_token=pcb.1234" to false, - "#test-id" to false, - "#" to false, - "#!" to false, - "#!/" to false, - "#!/events/permalink/going/?event_id=" to false, - "/this/is/valid" to true, - "#!/facebook/segment" to true - ).forEach { (url, valid) -> - assertEquals( - valid, url.isIndependent, - "Independence test failed for $url; should be $valid" - ) - } - } + mapOf( + GOOGLE to true, + FACEBOOK_COM to true, + "#!/photos/viewer/?photoset_token=pcb.1234" to false, + "#test-id" to false, + "#" to false, + "#!" to false, + "#!/" to false, + "#!/events/permalink/going/?event_id=" to false, + "/this/is/valid" to true, + "#!/facebook/segment" to true + ) + .forEach { (url, valid) -> + assertEquals( + valid, + url.isIndependent, + "Independence test failed for $url; should be $valid" + ) + } + } - @Test - fun isFacebook() { - assertFalse(GOOGLE.isFacebookUrl, "google") - assertTrue(FACEBOOK_COM.isFacebookUrl, "facebook") - } + @Test + fun isFacebook() { + assertFalse(GOOGLE.isFacebookUrl, "google") + assertTrue(FACEBOOK_COM.isFacebookUrl, "facebook") + } - @Test - fun queryEncoding() { - assertEquals("%23foo", "#foo".urlEncode()) - } + @Test + fun queryEncoding() { + assertEquals("%23foo", "#foo".urlEncode()) + } } diff --git a/buildSrc/src/main/kotlin/Versions.kt b/buildSrc/src/main/kotlin/Versions.kt index 5cbefda77..92802f8d4 100644 --- a/buildSrc/src/main/kotlin/Versions.kt +++ b/buildSrc/src/main/kotlin/Versions.kt @@ -1,26 +1,32 @@ object Versions { - const val targetSdk = 33 + const val targetSdk = 33 - // https://developer.android.com/jetpack/androidx/releases/biometric - const val andxBiometric = "1.1.0" + // https://developer.android.com/jetpack/androidx/releases/biometric + const val andxBiometric = "1.1.0" - // https://mvnrepository.com/artifact/org.apache.commons/commons-text - // Updates blocked due to javax.script dependency - const val apacheCommonsText = "1.4" - // https://github.com/brianwernick/ExoMedia/releases - const val exoMedia = "4.3.0" + // https://mvnrepository.com/artifact/org.apache.commons/commons-text + // Updates blocked due to javax.script dependency + const val apacheCommonsText = "1.4" - // https://github.com/jhy/jsoup/releases - const val jsoup = "1.15.3" - // https://square.github.io/okhttp/changelog/ - const val okhttp = "4.10.0" - // https://developer.android.com/jetpack/androidx/releases/room - const val room = "2.4.3" - // http://robolectric.org/getting-started/ - const val roboelectric = "4.8" - // https://github.com/Piasy/BigImageViewer#add-the-dependencies - const val bigImageViewer = "1.8.1" - // https://github.com/node-gradle/gradle-node-plugin/releases - const val nodeGradle = "3.4.0" -} \ No newline at end of file + // https://github.com/brianwernick/ExoMedia/releases + const val exoMedia = "4.3.0" + + // https://github.com/jhy/jsoup/releases + const val jsoup = "1.15.3" + + // https://square.github.io/okhttp/changelog/ + const val okhttp = "4.10.0" + + // https://developer.android.com/jetpack/androidx/releases/room + const val room = "2.4.3" + + // http://robolectric.org/getting-started/ + const val roboelectric = "4.8" + + // https://github.com/Piasy/BigImageViewer#add-the-dependencies + const val bigImageViewer = "1.8.1" + + // https://github.com/node-gradle/gradle-node-plugin/releases + const val nodeGradle = "3.4.0" +} diff --git a/spotless.gradle b/spotless.gradle index 6ff39f3c8..1f31b7912 100644 --- a/spotless.gradle +++ b/spotless.gradle @@ -3,7 +3,7 @@ apply plugin: "com.diffplug.spotless" spotless { kotlin { target "**/*.kt" - ktlint(kau.Versions.ktlint).userData(["disabled_rules": "no-wildcard-imports"]) + ktfmt(kau.Versions.ktfmt).googleStyle() licenseHeaderFile '../spotless.license.kt' trimTrailingWhitespace() endWithNewline()