1
0
mirror of https://github.com/AllanWang/Frost-for-Facebook.git synced 2024-09-18 21:12:24 +02:00

Apply ktfmt

This commit is contained in:
Allan Wang 2022-09-15 15:17:07 -07:00
parent bc1e1bda4f
commit a319baa736
No known key found for this signature in database
GPG Key ID: C93E3F9C679D7A56
160 changed files with 9910 additions and 10751 deletions

95
.editorconfig Normal file
View File

@ -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 <project root>/.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

View File

@ -22,11 +22,11 @@ import androidx.test.runner.AndroidJUnitRunner
import dagger.hilt.android.testing.HiltTestApplication import dagger.hilt.android.testing.HiltTestApplication
class FrostTestRunner : AndroidJUnitRunner() { class FrostTestRunner : AndroidJUnitRunner() {
override fun newApplication( override fun newApplication(
cl: ClassLoader?, cl: ClassLoader?,
className: String?, className: String?,
context: Context? context: Context?
): Application { ): Application {
return super.newApplication(cl, HiltTestApplication::class.java.name, context) return super.newApplication(cl, HiltTestApplication::class.java.name, context)
} }
} }

View File

@ -27,22 +27,18 @@ import org.junit.Test
@HiltAndroidTest @HiltAndroidTest
class StartActivityTest { class StartActivityTest {
@get:Rule(order = 0) @get:Rule(order = 0) val hildAndroidRule = HiltAndroidRule(this)
val hildAndroidRule = HiltAndroidRule(this)
@get:Rule(order = 1) @get:Rule(order = 1)
val activityRule = activityRule<StartActivity>( val activityRule =
intentAction = { activityRule<StartActivity>(intentAction = { putExtra(ARG_URL, TEST_FORMATTED_URL) })
putExtra(ARG_URL, TEST_FORMATTED_URL)
}
)
@Test @Test
fun initializesSuccessfully() { fun initializesSuccessfully() {
activityRule.scenario.use { activityRule.scenario.use {
it.onActivity { it.onActivity {
// Verify no crash // Verify no crash
} }
}
} }
}
} }

View File

@ -25,11 +25,7 @@ import dagger.hilt.components.SingletonComponent
import dagger.hilt.testing.TestInstallIn import dagger.hilt.testing.TestInstallIn
@Module @Module
@TestInstallIn( @TestInstallIn(components = [SingletonComponent::class], replaces = [PrefFactoryModule::class])
components = [SingletonComponent::class],
replaces = [PrefFactoryModule::class]
)
object PrefFactoryTestModule { object PrefFactoryTestModule {
@Provides @Provides fun factory(): KPrefFactory = KPrefFactoryInMemory
fun factory(): KPrefFactory = KPrefFactoryInMemory
} }

View File

@ -25,18 +25,16 @@ import org.junit.Test
@HiltAndroidTest @HiltAndroidTest
class AboutActivityTest { class AboutActivityTest {
@get:Rule(order = 0) @get:Rule(order = 0) val hildAndroidRule = HiltAndroidRule(this)
val hildAndroidRule = HiltAndroidRule(this)
@get:Rule(order = 1) @get:Rule(order = 1) val activityRule = activityRule<AboutActivity>()
val activityRule = activityRule<AboutActivity>()
@Test @Test
fun initializesSuccessfully() { fun initializesSuccessfully() {
activityRule.scenario.use { activityRule.scenario.use {
it.onActivity { it.onActivity {
// Verify no crash // Verify no crash
} }
}
} }
}
} }

View File

@ -25,18 +25,16 @@ import org.junit.Test
@HiltAndroidTest @HiltAndroidTest
class DebugActivityTest { class DebugActivityTest {
@get:Rule(order = 0) @get:Rule(order = 0) val hildAndroidRule = HiltAndroidRule(this)
val hildAndroidRule = HiltAndroidRule(this)
@get:Rule(order = 1) @get:Rule(order = 1) val activityRule = activityRule<DebugActivity>()
val activityRule = activityRule<DebugActivity>()
@Test @Test
fun initializesSuccessfully() { fun initializesSuccessfully() {
activityRule.scenario.use { activityRule.scenario.use {
it.onActivity { it.onActivity {
// Verify no crash // Verify no crash
} }
}
} }
}
} }

View File

@ -27,22 +27,18 @@ import org.junit.Test
@HiltAndroidTest @HiltAndroidTest
class FrostWebActivityTest { class FrostWebActivityTest {
@get:Rule(order = 0) @get:Rule(order = 0) val hildAndroidRule = HiltAndroidRule(this)
val hildAndroidRule = HiltAndroidRule(this)
@get:Rule(order = 1) @get:Rule(order = 1)
val activityRule = activityRule<FrostWebActivity>( val activityRule =
intentAction = { activityRule<FrostWebActivity>(intentAction = { putExtra(ARG_URL, TEST_FORMATTED_URL) })
putExtra(ARG_URL, TEST_FORMATTED_URL)
}
)
@Test @Test
fun initializesSuccessfully() { fun initializesSuccessfully() {
activityRule.scenario.use { activityRule.scenario.use {
it.onActivity { it.onActivity {
// Verify no crash // Verify no crash
} }
}
} }
}
} }

View File

@ -29,6 +29,11 @@ import com.pitchedapps.frost.utils.ARG_TEXT
import com.pitchedapps.frost.utils.isIndirectImageUrl import com.pitchedapps.frost.utils.isIndirectImageUrl
import dagger.hilt.android.testing.HiltAndroidRule import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest 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.internal.closeQuietly
import okhttp3.mockwebserver.Dispatcher import okhttp3.mockwebserver.Dispatcher
import okhttp3.mockwebserver.MockResponse import okhttp3.mockwebserver.MockResponse
@ -42,140 +47,125 @@ import org.junit.Ignore
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test
import org.junit.rules.Timeout 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 @HiltAndroidTest
class ImageActivityTest { class ImageActivityTest {
@get:Rule(order = 0) @get:Rule(order = 0) val hildAndroidRule = HiltAndroidRule(this)
val hildAndroidRule = HiltAndroidRule(this)
@get:Rule(order = 1) @get:Rule(order = 1)
val activityRule = activityRule<ImageActivity>( val activityRule =
intentAction = { activityRule<ImageActivity>(intentAction = { putExtra(ARG_IMAGE_URL, TEST_FORMATTED_URL) })
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<ImageActivity>(intent).use { it.onActivity(action) }
}
@get:Rule(order = 2) private fun mockServer(): MockWebServer {
val globalTimeout: Timeout = Timeout.seconds(15) val img = Buffer()
img.writeAll(getResource("bayer-pattern.jpg").source())
lateinit var mockServer: MockWebServer return MockWebServer().apply {
dispatcher =
@Before object : Dispatcher() {
fun before() { override fun dispatch(request: RecordedRequest): MockResponse =
mockServer = mockServer() when {
} request.path?.contains("text") == true ->
MockResponse().setResponseCode(200).setBody("Valid mock text response")
@After request.path?.contains("image") == true ->
fun after() { MockResponse().setResponseCode(200).setBody(img)
mockServer.closeQuietly() else -> MockResponse().setResponseCode(404).setBody("Error mock response")
}
@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<ImageActivity>(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()
} }
}
} }

View File

@ -25,18 +25,16 @@ import org.junit.Test
@HiltAndroidTest @HiltAndroidTest
class IntroActivityTest { class IntroActivityTest {
@get:Rule(order = 0) @get:Rule(order = 0) val hildAndroidRule = HiltAndroidRule(this)
val hildAndroidRule = HiltAndroidRule(this)
@get:Rule(order = 1) @get:Rule(order = 1) val activityRule = activityRule<IntroActivity>()
val activityRule = activityRule<IntroActivity>()
@Test @Test
fun initializesSuccessfully() { fun initializesSuccessfully() {
activityRule.scenario.use { activityRule.scenario.use {
it.onActivity { it.onActivity {
// Verify no crash // Verify no crash
} }
}
} }
}
} }

View File

@ -25,18 +25,16 @@ import org.junit.Test
@HiltAndroidTest @HiltAndroidTest
class LoginActivityTest { class LoginActivityTest {
@get:Rule(order = 0) @get:Rule(order = 0) val hildAndroidRule = HiltAndroidRule(this)
val hildAndroidRule = HiltAndroidRule(this)
@get:Rule(order = 1) @get:Rule(order = 1) val activityRule = activityRule<LoginActivity>()
val activityRule = activityRule<LoginActivity>()
@Test @Test
fun initializesSuccessfully() { fun initializesSuccessfully() {
activityRule.scenario.use { activityRule.scenario.use {
it.onActivity { it.onActivity {
// Verify no crash // Verify no crash
} }
}
} }
}
} }

View File

@ -25,18 +25,16 @@ import org.junit.Test
@HiltAndroidTest @HiltAndroidTest
class MainActivityTest { class MainActivityTest {
@get:Rule(order = 0) @get:Rule(order = 0) val hildAndroidRule = HiltAndroidRule(this)
val hildAndroidRule = HiltAndroidRule(this)
@get:Rule(order = 1) @get:Rule(order = 1) val activityRule = activityRule<MainActivity>()
val activityRule = activityRule<MainActivity>()
@Test @Test
fun initializesSuccessfully() { fun initializesSuccessfully() {
activityRule.scenario.use { activityRule.scenario.use {
it.onActivity { it.onActivity {
// Verify no crash // Verify no crash
} }
}
} }
}
} }

View File

@ -25,18 +25,16 @@ import org.junit.Test
@HiltAndroidTest @HiltAndroidTest
class SelectorActivityTest { class SelectorActivityTest {
@get:Rule(order = 0) @get:Rule(order = 0) val hildAndroidRule = HiltAndroidRule(this)
val hildAndroidRule = HiltAndroidRule(this)
@get:Rule(order = 1) @get:Rule(order = 1) val activityRule = activityRule<SelectorActivity>()
val activityRule = activityRule<SelectorActivity>()
@Test @Test
fun initializesSuccessfully() { fun initializesSuccessfully() {
activityRule.scenario.use { activityRule.scenario.use {
it.onActivity { it.onActivity {
// Verify no crash // Verify no crash
} }
}
} }
}
} }

View File

@ -25,18 +25,16 @@ import org.junit.Test
@HiltAndroidTest @HiltAndroidTest
class SettingActivityTest { class SettingActivityTest {
@get:Rule(order = 0) @get:Rule(order = 0) val hildAndroidRule = HiltAndroidRule(this)
val hildAndroidRule = HiltAndroidRule(this)
@get:Rule(order = 1) @get:Rule(order = 1) val activityRule = activityRule<SettingsActivity>()
val activityRule = activityRule<SettingsActivity>()
@Test @Test
fun initializesSuccessfully() { fun initializesSuccessfully() {
activityRule.scenario.use { activityRule.scenario.use {
it.onActivity { it.onActivity {
// Verify no crash // Verify no crash
} }
}
} }
}
} }

View File

@ -25,18 +25,16 @@ import org.junit.Test
@HiltAndroidTest @HiltAndroidTest
class TabCustomizerActivityTest { class TabCustomizerActivityTest {
@get:Rule(order = 0) @get:Rule(order = 0) val hildAndroidRule = HiltAndroidRule(this)
val hildAndroidRule = HiltAndroidRule(this)
@get:Rule(order = 1) @get:Rule(order = 1) val activityRule = activityRule<TabCustomizerActivity>()
val activityRule = activityRule<TabCustomizerActivity>()
@Test @Test
fun initializesSuccessfully() { fun initializesSuccessfully() {
activityRule.scenario.use { activityRule.scenario.use {
it.onActivity { it.onActivity {
// Verify no crash // Verify no crash
} }
}
} }
}
} }

View File

@ -25,18 +25,16 @@ import org.junit.Test
@HiltAndroidTest @HiltAndroidTest
class WebOverlayActivityTest { class WebOverlayActivityTest {
@get:Rule(order = 0) @get:Rule(order = 0) val hildAndroidRule = HiltAndroidRule(this)
val hildAndroidRule = HiltAndroidRule(this)
@get:Rule(order = 1) @get:Rule(order = 1) val activityRule = activityRule<AboutActivity>()
val activityRule = activityRule<AboutActivity>()
@Test @Test
fun initializesSuccessfully() { fun initializesSuccessfully() {
activityRule.scenario.use { activityRule.scenario.use {
it.onActivity { it.onActivity {
// Verify no crash // Verify no crash
} }
}
} }
}
} }

View File

@ -20,29 +20,25 @@ import android.content.Context
import androidx.room.Room import androidx.room.Room
import androidx.test.core.app.ApplicationProvider import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.runner.RunWith
import kotlin.test.AfterTest import kotlin.test.AfterTest
import kotlin.test.BeforeTest import kotlin.test.BeforeTest
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class) @RunWith(AndroidJUnit4::class)
abstract class BaseDbTest { abstract class BaseDbTest {
protected lateinit var db: FrostDatabase protected lateinit var db: FrostDatabase
@BeforeTest @BeforeTest
fun before() { fun before() {
val context = ApplicationProvider.getApplicationContext<Context>() val context = ApplicationProvider.getApplicationContext<Context>()
val privateDb = Room.inMemoryDatabaseBuilder( val privateDb = Room.inMemoryDatabaseBuilder(context, FrostPrivateDatabase::class.java).build()
context, FrostPrivateDatabase::class.java val publicDb = Room.inMemoryDatabaseBuilder(context, FrostPublicDatabase::class.java).build()
).build() db = FrostDatabase(privateDb, publicDb)
val publicDb = Room.inMemoryDatabaseBuilder( }
context, FrostPublicDatabase::class.java
).build()
db = FrostDatabase(privateDb, publicDb)
}
@AfterTest @AfterTest
fun after() { fun after() {
db.close() db.close()
} }
} }

View File

@ -16,33 +16,35 @@
*/ */
package com.pitchedapps.frost.db package com.pitchedapps.frost.db
import kotlinx.coroutines.runBlocking
import kotlin.test.Test import kotlin.test.Test
import kotlin.test.assertEquals import kotlin.test.assertEquals
import kotlin.test.assertTrue import kotlin.test.assertTrue
import kotlin.test.fail import kotlin.test.fail
import kotlinx.coroutines.runBlocking
class CacheDbTest : BaseDbTest() { class CacheDbTest : BaseDbTest() {
private val dao get() = db.cacheDao() private val dao
private val cookieDao get() = db.cookieDao() 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 @Test
fun save() { fun save() {
val cookie = cookie(1L) val cookie = cookie(1L)
val type = "test" val type = "test"
val content = "long test".repeat(10000) val content = "long test".repeat(10000)
runBlocking { runBlocking {
cookieDao.save(cookie) cookieDao.save(cookie)
dao.save(cookie.id, type, content) dao.save(cookie.id, type, content)
val cache = dao.select(cookie.id, type) ?: fail("Cache not found") val cache = dao.select(cookie.id, type) ?: fail("Cache not found")
assertEquals(content, cache.contents, "Content mismatch") assertEquals(content, cache.contents, "Content mismatch")
assertTrue( assertTrue(
System.currentTimeMillis() - cache.lastUpdated < 500, System.currentTimeMillis() - cache.lastUpdated < 500,
"Cache retrieval took over 500ms (${System.currentTimeMillis() - cache.lastUpdated})" "Cache retrieval took over 500ms (${System.currentTimeMillis() - cache.lastUpdated})"
) )
}
} }
}
} }

View File

@ -16,70 +16,71 @@
*/ */
package com.pitchedapps.frost.db package com.pitchedapps.frost.db
import kotlinx.coroutines.runBlocking
import kotlin.test.Test import kotlin.test.Test
import kotlin.test.assertEquals import kotlin.test.assertEquals
import kotlin.test.assertNull import kotlin.test.assertNull
import kotlinx.coroutines.runBlocking
class CookieDbTest : BaseDbTest() { class CookieDbTest : BaseDbTest() {
private val dao get() = db.cookieDao() private val dao
get() = db.cookieDao()
@Test @Test
fun basicCookie() { fun basicCookie() {
val cookie = CookieEntity(id = 1234L, name = "testName", cookie = "testCookie") val cookie = CookieEntity(id = 1234L, name = "testName", cookie = "testCookie")
runBlocking { runBlocking {
dao.save(cookie) dao.save(cookie)
val cookies = dao.selectAll() val cookies = dao.selectAll()
assertEquals(listOf(cookie), cookies, "Cookie mismatch") assertEquals(listOf(cookie), cookies, "Cookie mismatch")
}
} }
}
@Test @Test
fun deleteCookie() { fun deleteCookie() {
val cookie = CookieEntity(id = 1234L, name = "testName", cookie = "testCookie") val cookie = CookieEntity(id = 1234L, name = "testName", cookie = "testCookie")
runBlocking { runBlocking {
dao.save(cookie) dao.save(cookie)
dao.deleteById(cookie.id + 1) dao.deleteById(cookie.id + 1)
assertEquals( assertEquals(
listOf(cookie), listOf(cookie),
dao.selectAll(), dao.selectAll(),
"Cookie list should be the same after inexistent deletion" "Cookie list should be the same after inexistent deletion"
) )
dao.deleteById(cookie.id) dao.deleteById(cookie.id)
assertEquals(emptyList(), dao.selectAll(), "Cookie list should be empty after deletion") assertEquals(emptyList(), dao.selectAll(), "Cookie list should be empty after deletion")
}
} }
}
@Test @Test
fun insertReplaceCookie() { fun insertReplaceCookie() {
val cookie = CookieEntity(id = 1234L, name = "testName", cookie = "testCookie") val cookie = CookieEntity(id = 1234L, name = "testName", cookie = "testCookie")
runBlocking { runBlocking {
dao.save(cookie) dao.save(cookie)
assertEquals(listOf(cookie), dao.selectAll(), "Cookie insertion failed") assertEquals(listOf(cookie), dao.selectAll(), "Cookie insertion failed")
dao.save(cookie.copy(name = "testName2")) dao.save(cookie.copy(name = "testName2"))
assertEquals( assertEquals(
listOf(cookie.copy(name = "testName2")), listOf(cookie.copy(name = "testName2")),
dao.selectAll(), dao.selectAll(),
"Cookie replacement failed" "Cookie replacement failed"
) )
dao.save(cookie.copy(id = 123L)) dao.save(cookie.copy(id = 123L))
assertEquals( assertEquals(
setOf(cookie.copy(id = 123L), cookie.copy(name = "testName2")), setOf(cookie.copy(id = 123L), cookie.copy(name = "testName2")),
dao.selectAll().toSet(), dao.selectAll().toSet(),
"New cookie insertion failed" "New cookie insertion failed"
) )
}
} }
}
@Test @Test
fun selectCookie() { fun selectCookie() {
val cookie = CookieEntity(id = 1234L, name = "testName", cookie = "testCookie") val cookie = CookieEntity(id = 1234L, name = "testName", cookie = "testCookie")
runBlocking { runBlocking {
dao.save(cookie) dao.save(cookie)
assertEquals(cookie, dao.selectById(cookie.id), "Cookie selection failed") assertEquals(cookie, dao.selectById(cookie.id), "Cookie selection failed")
assertNull(dao.selectById(cookie.id + 1), "Inexistent cookie selection failed") assertNull(dao.selectById(cookie.id + 1), "Inexistent cookie selection failed")
}
} }
}
} }

View File

@ -21,38 +21,40 @@ import androidx.room.testing.MigrationTestHelper
import androidx.sqlite.db.framework.FrameworkSQLiteOpenHelperFactory import androidx.sqlite.db.framework.FrameworkSQLiteOpenHelperFactory
import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry import androidx.test.platform.app.InstrumentationRegistry
import org.junit.runner.RunWith
import kotlin.test.Test import kotlin.test.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class) @RunWith(AndroidJUnit4::class)
class CookieMigrationTest { 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( val helper: MigrationTestHelper =
InstrumentationRegistry.getInstrumentation(), MigrationTestHelper(
FrostPrivateDatabase::class.java.canonicalName, InstrumentationRegistry.getInstrumentation(),
FrameworkSQLiteOpenHelperFactory() FrostPrivateDatabase::class.java.canonicalName,
FrameworkSQLiteOpenHelperFactory()
) )
@Test @Test
fun migrateAll() { fun migrateAll() {
// Create earliest version of the database. // Create earliest version of the database.
helper.createDatabase(TEST_DB, 1).apply { helper.createDatabase(TEST_DB, 1).apply { close() }
close()
}
// Open latest version of the database. Room will validate the schema // Open latest version of the database. Room will validate the schema
// once all migrations execute. // once all migrations execute.
Room.databaseBuilder( Room.databaseBuilder(
InstrumentationRegistry.getInstrumentation().targetContext, InstrumentationRegistry.getInstrumentation().targetContext,
FrostPrivateDatabase::class.java, FrostPrivateDatabase::class.java,
TEST_DB TEST_DB
).addMigrations(*ALL_MIGRATIONS).build().apply { )
openHelper.writableDatabase .addMigrations(*ALL_MIGRATIONS)
close() .build()
} .apply {
} openHelper.writableDatabase
close()
}
}
} }

View File

@ -18,56 +18,54 @@ package com.pitchedapps.frost.db
import com.pitchedapps.frost.facebook.FbItem import com.pitchedapps.frost.facebook.FbItem
import com.pitchedapps.frost.facebook.defaultTabs import com.pitchedapps.frost.facebook.defaultTabs
import kotlinx.coroutines.runBlocking
import kotlin.test.Test import kotlin.test.Test
import kotlin.test.assertEquals import kotlin.test.assertEquals
import kotlinx.coroutines.runBlocking
class GenericDbTest : BaseDbTest() { class GenericDbTest : BaseDbTest() {
private val dao get() = db.genericDao() private val dao
get() = db.genericDao()
/** /** Note that order is also preserved here */
* Note that order is also preserved here @Test
*/ fun save() {
@Test val tabs =
fun save() { listOf(
val tabs = listOf( FbItem.ACTIVITY_LOG,
FbItem.ACTIVITY_LOG, FbItem.BIRTHDAYS,
FbItem.BIRTHDAYS, FbItem.EVENTS,
FbItem.EVENTS, FbItem.MARKETPLACE,
FbItem.MARKETPLACE, FbItem.ACTIVITY_LOG
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(
assertEquals(tabs, dao.getTabs(), "Tab saving failed") listOf(FbItem.ACTIVITY_LOG, FbItem.EVENTS),
val newTabs = listOf(FbItem.PAGES, FbItem.MENU) dao.getTabs(),
dao.saveTabs(newTabs) "Tab fetching does not ignore unknown names"
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"
)
}
} }
}
} }

View File

@ -19,139 +19,134 @@ package com.pitchedapps.frost.db
import com.pitchedapps.frost.services.NOTIF_CHANNEL_GENERAL import com.pitchedapps.frost.services.NOTIF_CHANNEL_GENERAL
import com.pitchedapps.frost.services.NOTIF_CHANNEL_MESSAGES import com.pitchedapps.frost.services.NOTIF_CHANNEL_MESSAGES
import com.pitchedapps.frost.services.NotificationContent import com.pitchedapps.frost.services.NotificationContent
import kotlinx.coroutines.runBlocking
import kotlin.test.Test import kotlin.test.Test
import kotlin.test.assertEquals import kotlin.test.assertEquals
import kotlin.test.assertFalse import kotlin.test.assertFalse
import kotlin.test.assertTrue import kotlin.test.assertTrue
import kotlinx.coroutines.runBlocking
class NotificationDbTest : BaseDbTest() { 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( private fun notifContent(id: Long, cookie: CookieEntity, time: Long = id) =
data = cookie, NotificationContent(
id = id, data = cookie,
href = "", id = id,
title = null, href = "",
text = "", title = null,
timestamp = time, text = "",
profileUrl = null, timestamp = time,
unread = true profileUrl = null,
unread = true
) )
@Test @Test
fun saveAndRetrieve() { fun saveAndRetrieve() {
val cookie = cookie(12345L) val cookie = cookie(12345L)
// Unique unsorted ids // Unique unsorted ids
val notifs = listOf(0L, 4L, 2L, 6L, 99L, 3L).map { notifContent(it, cookie) } val notifs = listOf(0L, 4L, 2L, 6L, 99L, 3L).map { notifContent(it, cookie) }
runBlocking { runBlocking {
db.cookieDao().save(cookie) db.cookieDao().save(cookie)
dao.saveNotifications(NOTIF_CHANNEL_GENERAL, notifs) dao.saveNotifications(NOTIF_CHANNEL_GENERAL, notifs)
val dbNotifs = dao.selectNotifications(cookie.id, NOTIF_CHANNEL_GENERAL) val dbNotifs = dao.selectNotifications(cookie.id, NOTIF_CHANNEL_GENERAL)
assertEquals( assertEquals(
notifs.sortedByDescending { it.timestamp }, notifs.sortedByDescending { it.timestamp },
dbNotifs, dbNotifs,
"Incorrect notification list received" "Incorrect notification list received"
) )
}
} }
}
@Test @Test
fun selectConditions() { fun selectConditions() {
runBlocking { runBlocking {
val cookie1 = cookie(12345L) val cookie1 = cookie(12345L)
val cookie2 = cookie(12L) val cookie2 = cookie(12L)
val notifs1 = (0L..2L).map { notifContent(it, cookie1) } val notifs1 = (0L..2L).map { notifContent(it, cookie1) }
val notifs2 = (5L..10L).map { notifContent(it, cookie2) } val notifs2 = (5L..10L).map { notifContent(it, cookie2) }
db.cookieDao().save(cookie1) db.cookieDao().save(cookie1)
db.cookieDao().save(cookie2) db.cookieDao().save(cookie2)
dao.saveNotifications(NOTIF_CHANNEL_GENERAL, notifs1) dao.saveNotifications(NOTIF_CHANNEL_GENERAL, notifs1)
dao.saveNotifications(NOTIF_CHANNEL_MESSAGES, notifs2) dao.saveNotifications(NOTIF_CHANNEL_MESSAGES, notifs2)
assertEquals( assertEquals(
emptyList(), emptyList(),
dao.selectNotifications(cookie1.id, NOTIF_CHANNEL_MESSAGES), dao.selectNotifications(cookie1.id, NOTIF_CHANNEL_MESSAGES),
"Filtering by type did not work for cookie1" "Filtering by type did not work for cookie1"
) )
assertEquals( assertEquals(
notifs1.sortedByDescending { it.timestamp }, notifs1.sortedByDescending { it.timestamp },
dao.selectNotifications(cookie1.id, NOTIF_CHANNEL_GENERAL), dao.selectNotifications(cookie1.id, NOTIF_CHANNEL_GENERAL),
"Selection for cookie1 failed" "Selection for cookie1 failed"
) )
assertEquals( assertEquals(
emptyList(), emptyList(),
dao.selectNotifications(cookie2.id, NOTIF_CHANNEL_GENERAL), dao.selectNotifications(cookie2.id, NOTIF_CHANNEL_GENERAL),
"Filtering by type did not work for cookie2" "Filtering by type did not work for cookie2"
) )
assertEquals( assertEquals(
notifs2.sortedByDescending { it.timestamp }, notifs2.sortedByDescending { it.timestamp },
dao.selectNotifications(cookie2.id, NOTIF_CHANNEL_MESSAGES), dao.selectNotifications(cookie2.id, NOTIF_CHANNEL_MESSAGES),
"Selection for cookie2 failed" "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 * Primary key is both id and userId, in the event that the same notification to multiple users
*/ * has the same id
@Test */
fun primaryKeyCheck() { @Test
runBlocking { fun primaryKeyCheck() {
val cookie1 = cookie(12345L) runBlocking {
val cookie2 = cookie(12L) val cookie1 = cookie(12345L)
val notifs1 = (0L..2L).map { notifContent(it, cookie1) } val cookie2 = cookie(12L)
val notifs2 = notifs1.map { it.copy(data = cookie2) } val notifs1 = (0L..2L).map { notifContent(it, cookie1) }
db.cookieDao().save(cookie1) val notifs2 = notifs1.map { it.copy(data = cookie2) }
db.cookieDao().save(cookie2) db.cookieDao().save(cookie1)
assertTrue(dao.saveNotifications(NOTIF_CHANNEL_GENERAL, notifs1), "Notif1 save failed") db.cookieDao().save(cookie2)
assertTrue(dao.saveNotifications(NOTIF_CHANNEL_GENERAL, notifs2), "Notif2 save failed") assertTrue(dao.saveNotifications(NOTIF_CHANNEL_GENERAL, notifs1), "Notif1 save failed")
} assertTrue(dao.saveNotifications(NOTIF_CHANNEL_GENERAL, notifs2), "Notif2 save failed")
} }
}
@Test @Test
fun cascadeDeletion() { fun cascadeDeletion() {
val cookie = cookie(12345L) val cookie = cookie(12345L)
// Unique unsorted ids // Unique unsorted ids
val notifs = listOf(0L, 4L, 2L, 6L, 99L, 3L).map { notifContent(it, cookie) } val notifs = listOf(0L, 4L, 2L, 6L, 99L, 3L).map { notifContent(it, cookie) }
runBlocking { runBlocking {
db.cookieDao().save(cookie) db.cookieDao().save(cookie)
dao.saveNotifications(NOTIF_CHANNEL_GENERAL, notifs) dao.saveNotifications(NOTIF_CHANNEL_GENERAL, notifs)
db.cookieDao().deleteById(cookie.id) db.cookieDao().deleteById(cookie.id)
val dbNotifs = dao.selectNotifications(cookie.id, NOTIF_CHANNEL_GENERAL) val dbNotifs = dao.selectNotifications(cookie.id, NOTIF_CHANNEL_GENERAL)
assertTrue(dbNotifs.isEmpty(), "Cascade deletion failed") assertTrue(dbNotifs.isEmpty(), "Cascade deletion failed")
}
} }
}
@Test @Test
fun latestEpoch() { fun latestEpoch() {
val cookie = cookie(12345L) val cookie = cookie(12345L)
// Unique unsorted ids // Unique unsorted ids
val notifs = listOf(0L, 4L, 2L, 6L, 99L, 3L).map { notifContent(it, cookie) } val notifs = listOf(0L, 4L, 2L, 6L, 99L, 3L).map { notifContent(it, cookie) }
runBlocking { runBlocking {
assertEquals( assertEquals(-1L, dao.latestEpoch(cookie.id, NOTIF_CHANNEL_GENERAL), "Default epoch failed")
-1L, db.cookieDao().save(cookie)
dao.latestEpoch(cookie.id, NOTIF_CHANNEL_GENERAL), dao.saveNotifications(NOTIF_CHANNEL_GENERAL, notifs)
"Default epoch failed" assertEquals(99L, dao.latestEpoch(cookie.id, NOTIF_CHANNEL_GENERAL), "Latest 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 @Test
fun insertionWithInvalidCookies() { fun insertionWithInvalidCookies() {
runBlocking { runBlocking {
assertFalse( assertFalse(
dao.saveNotifications(NOTIF_CHANNEL_GENERAL, listOf(notifContent(1L, cookie(2L)))), dao.saveNotifications(NOTIF_CHANNEL_GENERAL, listOf(notifContent(1L, cookie(2L)))),
"Notif save should not have passed without relevant cookie entries" "Notif save should not have passed without relevant cookie entries"
) )
}
} }
}
} }

View File

@ -17,16 +17,16 @@
package com.pitchedapps.frost.facebook package com.pitchedapps.frost.facebook
import android.webkit.CookieManager import android.webkit.CookieManager
import org.junit.Test
import kotlin.test.assertTrue import kotlin.test.assertTrue
import org.junit.Test
class FbCookieTest { class FbCookieTest {
@Test @Test
fun managerAcceptsCookie() { fun managerAcceptsCookie() {
assertTrue( assertTrue(
CookieManager.getInstance().acceptCookie(), CookieManager.getInstance().acceptCookie(),
"Cookie manager should accept cookie by default" "Cookie manager should accept cookie by default"
) )
} }
} }

View File

@ -26,23 +26,21 @@ import androidx.test.platform.app.InstrumentationRegistry
import java.io.InputStream import java.io.InputStream
val context: Context val context: Context
get() = InstrumentationRegistry.getInstrumentation().targetContext get() = InstrumentationRegistry.getInstrumentation().targetContext
fun getAsset(asset: String): InputStream = fun getAsset(asset: String): InputStream = context.assets.open(asset)
context.assets.open(asset)
private class Helper private class Helper
fun getResource(resource: String): InputStream = fun getResource(resource: String): InputStream =
Helper::class.java.classLoader!!.getResource(resource).openStream() Helper::class.java.classLoader!!.getResource(resource).openStream()
inline fun <reified A : Activity> activityRule( inline fun <reified A : Activity> activityRule(
intentAction: Intent.() -> Unit = {}, intentAction: Intent.() -> Unit = {},
activityOptions: Bundle? = null activityOptions: Bundle? = null
): ActivityScenarioRule<A> { ): ActivityScenarioRule<A> {
val intent = val intent = Intent(ApplicationProvider.getApplicationContext(), A::class.java).also(intentAction)
Intent(ApplicationProvider.getApplicationContext(), A::class.java).also(intentAction) return ActivityScenarioRule(intent, activityOptions)
return ActivityScenarioRule(intent, activityOptions)
} }
const val TEST_FORMATTED_URL = "https://www.google.com" const val TEST_FORMATTED_URL = "https://www.google.com"

View File

@ -37,78 +37,75 @@ import dagger.hilt.android.HiltAndroidApp
import java.util.Random import java.util.Random
import javax.inject.Inject import javax.inject.Inject
/** /** Created by Allan Wang on 2017-05-28. */
* Created by Allan Wang on 2017-05-28.
*/
@HiltAndroidApp @HiltAndroidApp
class FrostApp : Application() { class FrostApp : Application() {
@Inject @Inject lateinit var prefs: Prefs
lateinit var prefs: Prefs
@Inject @Inject lateinit var themeProvider: ThemeProvider
lateinit var themeProvider: ThemeProvider
@Inject @Inject lateinit var cookieDao: CookieDao
lateinit var cookieDao: CookieDao
@Inject @Inject lateinit var notifDao: NotificationDao
lateinit var notifDao: NotificationDao
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
if (!buildIsLollipopAndUp) return // not supported if (!buildIsLollipopAndUp) return // not supported
initPrefs() initPrefs()
L.i { "Begin Frost for Facebook" } L.i { "Begin Frost for Facebook" }
FrostPglAdBlock.init(this) 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) { if (BuildConfig.DEBUG) {
registerActivityLifecycleCallbacks(object : ActivityLifecycleCallbacks { registerActivityLifecycleCallbacks(
override fun onActivityPaused(activity: Activity) {} object : ActivityLifecycleCallbacks {
override fun onActivityResumed(activity: Activity) {} override fun onActivityPaused(activity: Activity) {}
override fun onActivityStarted(activity: Activity) {} override fun onActivityResumed(activity: Activity) {}
override fun onActivityStarted(activity: Activity) {}
override fun onActivityDestroyed(activity: Activity) { override fun onActivityDestroyed(activity: Activity) {
L.d { "Activity ${activity.localClassName} destroyed" } 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?) { override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) {
L.d { "Activity ${activity.localClassName} created" } L.d { "Activity ${activity.localClassName} created" }
} }
})
} }
)
} }
}
private fun initPrefs() { private fun initPrefs() {
prefs.deleteKeys("search_bar", "shown_release", "experimental_by_default") prefs.deleteKeys("search_bar", "shown_release", "experimental_by_default")
KL.shouldLog = { BuildConfig.DEBUG } KL.shouldLog = { BuildConfig.DEBUG }
L.shouldLog = { L.shouldLog = {
when (it) { when (it) {
Log.VERBOSE -> BuildConfig.DEBUG Log.VERBOSE -> BuildConfig.DEBUG
Log.INFO, Log.ERROR -> true Log.INFO,
else -> BuildConfig.DEBUG || prefs.verboseLogging 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()
} }
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()
}
} }

View File

@ -45,97 +45,90 @@ import com.pitchedapps.frost.utils.L
import com.pitchedapps.frost.utils.launchNewTask import com.pitchedapps.frost.utils.launchNewTask
import com.pitchedapps.frost.utils.loadAssets import com.pitchedapps.frost.utils.loadAssets
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.launch
import java.util.ArrayList import java.util.ArrayList
import javax.inject.Inject 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 @AndroidEntryPoint
class StartActivity : KauBaseActivity() { class StartActivity : KauBaseActivity() {
@Inject @Inject lateinit var fbCookie: FbCookie
lateinit var fbCookie: FbCookie
@Inject @Inject lateinit var prefs: Prefs
lateinit var prefs: Prefs
@Inject @Inject lateinit var themeProvider: ThemeProvider
lateinit var themeProvider: ThemeProvider
@Inject @Inject lateinit var cookieDao: CookieDao
lateinit var cookieDao: CookieDao
@Inject @Inject lateinit var genericDao: GenericDao
lateinit var genericDao: GenericDao
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
if (!buildIsLollipopAndUp) { // not supported if (!buildIsLollipopAndUp) { // not supported
showInvalidSdkView() showInvalidSdkView()
return return
} }
try { try {
// TODO add better descriptions // TODO add better descriptions
CookieManager.getInstance() CookieManager.getInstance()
} catch (e: Exception) { } catch (e: Exception) {
L.e(e) { "No cookiemanager instance" } L.e(e) { "No cookiemanager instance" }
showInvalidWebView() showInvalidWebView()
} }
launch { launch {
try { try {
val authDefer = BiometricUtils.authenticate(this@StartActivity, prefs) val authDefer = BiometricUtils.authenticate(this@StartActivity, prefs)
fbCookie.switchBackUser() fbCookie.switchBackUser()
val cookies = ArrayList(cookieDao.selectAll()) val cookies = ArrayList(cookieDao.selectAll())
L.i { "Cookies loaded at time ${System.currentTimeMillis()}" } L.i { "Cookies loaded at time ${System.currentTimeMillis()}" }
L._d { L._d {
"Cookies: ${ "Cookies: ${
cookies.joinToString( cookies.joinToString(
"\t", "\t",
transform = CookieEntity::toSensitiveString transform = CookieEntity::toSensitiveString
) )
}" }"
}
loadAssets(themeProvider)
authDefer.await()
when {
cookies.isEmpty() -> launchNewTask<LoginActivity>()
// Has cookies but no selected account
prefs.userId == -1L -> launchNewTask<SelectorActivity>(cookies)
else -> startActivity<MainActivity>(
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<LoginActivity>()
// Has cookies but no selected account
prefs.userId == -1L -> launchNewTask<SelectorActivity>(cookies)
else ->
startActivity<MainActivity>(
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() = private fun showInvalidWebView() = showInvalidView(R.string.error_webview)
showInvalidView(R.string.error_webview)
private fun showInvalidSdkView() { private fun showInvalidSdkView() {
val text = String.format(string(R.string.error_sdk), Build.VERSION.SDK_INT) val text = String.format(string(R.string.error_sdk), Build.VERSION.SDK_INT)
showInvalidView(text) showInvalidView(text)
} }
private fun showInvalidView(textRes: Int) = private fun showInvalidView(textRes: Int) = showInvalidView(string(textRes))
showInvalidView(string(textRes))
private fun showInvalidView(text: String) { private fun showInvalidView(text: String) {
setContentView(R.layout.activity_invalid) setContentView(R.layout.activity_invalid)
findViewById<ImageView>(R.id.invalid_icon) findViewById<ImageView>(R.id.invalid_icon).setIcon(GoogleMaterial.Icon.gmd_adb, -1, Color.WHITE)
.setIcon(GoogleMaterial.Icon.gmd_adb, -1, Color.WHITE) findViewById<TextView>(R.id.invalid_text).text = text
findViewById<TextView>(R.id.invalid_text).text = text }
}
} }

View File

@ -52,154 +52,140 @@ import com.pitchedapps.frost.utils.L
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject import javax.inject.Inject
/** /** Created by Allan Wang on 2017-06-26. */
* Created by Allan Wang on 2017-06-26.
*/
@AndroidEntryPoint @AndroidEntryPoint
class AboutActivity : AboutActivityBase(null) { class AboutActivity : AboutActivityBase(null) {
@Inject @Inject lateinit var prefs: Prefs
lateinit var prefs: Prefs
@Inject @Inject lateinit var themeProvider: ThemeProvider
lateinit var themeProvider: ThemeProvider
override fun Configs.buildConfigs() { override fun Configs.buildConfigs() {
textColor = themeProvider.textColor textColor = themeProvider.textColor
accentColor = themeProvider.accentColor accentColor = themeProvider.accentColor
backgroundColor = themeProvider.bgColor.withMinAlpha(200) backgroundColor = themeProvider.bgColor.withMinAlpha(200)
cutoutForeground = themeProvider.accentColor cutoutForeground = themeProvider.accentColor
cutoutDrawableRes = R.drawable.frost_f_200 cutoutDrawableRes = R.drawable.frost_f_200
faqPageTitleRes = R.string.faq_title faqPageTitleRes = R.string.faq_title
faqXmlRes = R.xml.frost_faq faqXmlRes = R.xml.frost_faq
faqParseNewLine = false faqParseNewLine = false
}
var lastClick = -1L
var clickCount = 0
override fun postInflateMainPage(adapter: FastItemThemedAdapter<GenericItem>) {
/** 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<AboutLinks.ViewHolder>(), 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<Any>) {
super.bindView(holder, payloads)
with(holder) {
bindIconColor(*images.toTypedArray())
bindBackgroundColor(container)
}
} }
var lastClick = -1L class ViewHolder(v: View) : RecyclerView.ViewHolder(v) {
var clickCount = 0
override fun postInflateMainPage(adapter: FastItemThemedAdapter<GenericItem>) { val container: ConstraintLayout by bindView(R.id.about_icons_container)
/** val images: List<ImageView>
* Frost may not be a library but we're conveying the same info
*/ /**
val frost = Library( * There are a lot of constraints to be added to each item just to have them chained properly
uniqueId = "com.pitchedapps.frost", * My as well do it programmatically Initializing the viewholder will setup the icons, scale
name = string(R.string.frost_name), * type and background of all icons, link their click listeners and chain them together via a
developers = listOf( * horizontal spread
Developer(name = string(R.string.dev_name), organisationUrl = null) */
), init {
website = string(R.string.github_url), val c = itemView.context
description = string(R.string.frost_description), val size = c.dimenPixelSize(R.dimen.kau_avatar_bounds)
artifactVersion = BuildConfig.VERSION_NAME,
licenses = setOf( val icons: Array<Pair<Int, () -> Unit>> =
License( arrayOf(R.drawable.ic_fdroid_24 to { c.startLink(R.string.fdroid_url) })
spdxId = "gplv3", val iicons: Array<Pair<IIcon, () -> Unit>> =
name = "GNU GPL v3", arrayOf(
url = "https://www.gnu.org/licenses/gpl-3.0.en.html", GoogleMaterial.Icon.gmd_file_download to { c.startLink(R.string.github_downloads_url) },
hash = "gplv3" CommunityMaterial.Icon3.cmd_reddit to { c.startLink(R.string.reddit_url) },
) CommunityMaterial.Icon2.cmd_github to { c.startLink(R.string.github_url) }
), )
scm = null,
organization = null, 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()) set.applyTo(container)
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<AboutLinks.ViewHolder>(),
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<Any>) {
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<ImageView>
/**
* 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<Pair<Int, () -> Unit>> =
arrayOf(R.drawable.ic_fdroid_24 to { c.startLink(R.string.fdroid_url) })
val iicons: Array<Pair<IIcon, () -> 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)
}
}
} }
}
} }

View File

@ -28,48 +28,40 @@ import com.pitchedapps.frost.utils.ActivityThemer
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject import javax.inject.Inject
/** /** Created by Allan Wang on 2017-06-12. */
* Created by Allan Wang on 2017-06-12.
*/
@AndroidEntryPoint @AndroidEntryPoint
abstract class BaseActivity : KauBaseActivity() { abstract class BaseActivity : KauBaseActivity() {
@Inject @Inject lateinit var fbCookie: FbCookie
lateinit var fbCookie: FbCookie
@Inject @Inject lateinit var prefs: Prefs
lateinit var prefs: Prefs
@Inject @Inject lateinit var themeProvider: ThemeProvider
lateinit var themeProvider: ThemeProvider
@Inject @Inject lateinit var activityThemer: ActivityThemer
lateinit var activityThemer: ActivityThemer
/** /** Inherited consumer to customize back press */
* Inherited consumer to customize back press protected open fun backConsumer(): Boolean = false
*/
protected open fun backConsumer(): Boolean = false
final override fun onBackPressed() { final override fun onBackPressed() {
if (this is SearchViewHolder && searchViewOnBackPress()) return if (this is SearchViewHolder && searchViewOnBackPress()) return
if (this is VideoViewHolder && videoOnBackPress()) return if (this is VideoViewHolder && videoOnBackPress()) return
if (backConsumer()) return if (backConsumer()) return
super.onBackPressed() super.onBackPressed()
} }
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
if (this !is WebOverlayActivityBase) activityThemer.setFrostTheme() if (this !is WebOverlayActivityBase) activityThemer.setFrostTheme()
} }
override fun onStop() { override fun onStop() {
if (this is VideoViewHolder) videoOnStop() if (this is VideoViewHolder) videoOnStop()
super.onStop() super.onStop()
} }
override fun onConfigurationChanged(newConfig: Configuration) { override fun onConfigurationChanged(newConfig: Configuration) {
super.onConfigurationChanged(newConfig) super.onConfigurationChanged(newConfig)
if (this is VideoViewHolder) videoViewer?.updateLocation() if (this is VideoViewHolder) videoViewer?.updateLocation()
} }
} }

View File

@ -35,110 +35,97 @@ import com.pitchedapps.frost.utils.ActivityThemer
import com.pitchedapps.frost.utils.L import com.pitchedapps.frost.utils.L
import com.pitchedapps.frost.utils.createFreshDir import com.pitchedapps.frost.utils.createFreshDir
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.CoroutineExceptionHandler
import java.io.File import java.io.File
import javax.inject.Inject import javax.inject.Inject
import kotlin.coroutines.resume import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine 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 @AndroidEntryPoint
class DebugActivity : KauBaseActivity() { class DebugActivity : KauBaseActivity() {
companion object { companion object {
const val RESULT_URL = "extra_result_url" const val RESULT_URL = "extra_result_url"
const val RESULT_SCREENSHOT = "extra_result_screenshot" const val RESULT_SCREENSHOT = "extra_result_screenshot"
const val RESULT_BODY = "extra_result_body" const val RESULT_BODY = "extra_result_body"
fun baseDir(context: Context) = File(context.externalCacheDir, "offline_debug") 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 activityThemer.setFrostColors { toolbar(toolbar) }
lateinit var activityThemer: ActivityThemer debugWebview.loadUrl(FbItem.FEED.url)
debugWebview.onPageFinished = { swipeRefresh.isRefreshing = false }
@Inject swipeRefresh.setOnRefreshListener(debugWebview::reload)
lateinit var themeProvider: ThemeProvider
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?) { val errorHandler = CoroutineExceptionHandler { _, throwable ->
super.onCreate(savedInstanceState) L.e { "DebugActivity error ${throwable.message}" }
binding = ActivityDebugBinding.inflate(layoutInflater) setResult(Activity.RESULT_CANCELED)
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 {
finish() finish()
return true }
}
override fun onResume() { launchMain(errorHandler) {
super.onResume() val parent = baseDir(this@DebugActivity)
binding.debugWebview.resumeTimers() parent.createFreshDir()
}
override fun onPause() { val body: String? = suspendCoroutine { cont ->
binding.debugWebview.pauseTimers() debugWebview.evaluateJavascript(JsActions.RETURN_BODY.function) { cont.resume(it) }
super.onPause() }
}
override fun onBackPressed() { val hasScreenshot: Boolean = debugWebview.getScreenshot(File(parent, "screenshot.png"))
if (binding.debugWebview.canGoBack())
binding.debugWebview.goBack() val intent = Intent()
else intent.putExtra(RESULT_URL, debugWebview.url)
super.onBackPressed() 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()
}
} }

View File

@ -62,323 +62,309 @@ import com.pitchedapps.frost.utils.frostUriFromFile
import com.pitchedapps.frost.utils.isIndirectImageUrl import com.pitchedapps.frost.utils.isIndirectImageUrl
import com.pitchedapps.frost.utils.logFrostEvent import com.pitchedapps.frost.utils.logFrostEvent
import dagger.hilt.android.AndroidEntryPoint 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.File
import java.io.FileNotFoundException import java.io.FileNotFoundException
import javax.inject.Inject import javax.inject.Inject
import kotlin.math.abs import kotlin.math.abs
import kotlin.math.max 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 @AndroidEntryPoint
class ImageActivity : KauBaseActivity() { class ImageActivity : KauBaseActivity() {
@Inject @Inject lateinit var activityThemer: ActivityThemer
lateinit var activityThemer: ActivityThemer
@Inject @Inject lateinit var prefs: Prefs
lateinit var prefs: Prefs
@Inject @Inject lateinit var themeProvider: ThemeProvider
lateinit var themeProvider: ThemeProvider
@Volatile @Volatile internal var errorRef: Throwable? = null
internal var errorRef: Throwable? = null
/** /** Reference to the temporary file path */
* Reference to the temporary file path internal val tempFile: File?
*/ get() = binding.imagePhoto.currentImageFile
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<String> private lateinit var trueImageUrl: Deferred<String>
private val imageText: String? by lazy { intent.getStringExtra(ARG_TEXT) } private val imageText: String? by lazy { intent.getStringExtra(ARG_TEXT) }
lateinit var binding: ActivityImageBinding lateinit var binding: ActivityImageBinding
private var bottomBehavior: BottomSheetBehavior<View>? = null private var bottomBehavior: BottomSheetBehavior<View>? = null
private val baseBackgroundColor: Int private val baseBackgroundColor: Int
get() = if (prefs.blackMediaBg) Color.BLACK get() = if (prefs.blackMediaBg) Color.BLACK else themeProvider.bgColor.withMinAlpha(235)
else themeProvider.bgColor.withMinAlpha(235)
private fun loadError(e: Throwable) { private fun loadError(e: Throwable) {
if (e.message?.contains("<!DOCTYPE html>") == true) { if (e.message?.contains("<!DOCTYPE html>") == true) {
applicationContext.toast(R.string.image_not_found) 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<View>(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() 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?) { override fun onViewReleased(releasedChild: View, xvel: Float, yvel: Float) {
super.onCreate(savedInstanceState) val overScrolled = scrollPercent > scrollThreshold
if (imageUrl.isEmpty()) { val maxOffset = releasedChild.height + 10
return finish() val finalTop =
} when {
L.i { "Displaying image" } scrollToTop && (overScrolled || yvel < -dragHelper.minVelocity) -> -maxOffset
trueImageUrl = async(Dispatchers.IO) { !scrollToTop && (overScrolled || yvel > dragHelper.minVelocity) -> maxOffset
val result = if (!imageUrl.isIndirectImageUrl) imageUrl else -> 0
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())
} }
dragHelper.settleCapturedViewAt(0, finalTop)
binding.imageDrag.invalidate()
} }
private fun ActivityImageBinding.showImage(url: String) { override fun clampViewPositionHorizontal(child: View, left: Int, dx: Int): Int = 0
imagePhoto.showImage(Uri.parse(url))
imagePhoto.setImageShownCallback(object : ImageShownCallback {
override fun onThumbnailShown() {}
override fun onMainImageShown() { override fun clampViewPositionVertical(child: View, top: Int, dy: Int): Int = top
imageProgress.fadeOut() }
imagePhoto.animate().alpha(1f).scaleXY(1f).start()
}
})
}
private fun ActivityImageBinding.init() { internal suspend fun saveImage() {
imageContainer.setBackgroundColor(baseBackgroundColor) frostDownload(cookie = cookie, url = trueImageUrl.await())
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<View>(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) { companion object {
// No op private val L = KauLoggerExtension("Image", com.pitchedapps.frost.utils.L)
} }
})
}
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)
}
} }
internal enum class FabStates( internal enum class FabStates(
val iicon: IIcon, val iicon: IIcon,
val iconColorProvider: (ThemeProvider) -> Int = { it.iconColor }, val iconColorProvider: (ThemeProvider) -> Int = { it.iconColor },
val backgroundTint: Int = Int.MAX_VALUE val backgroundTint: Int = Int.MAX_VALUE
) { ) {
ERROR(GoogleMaterial.Icon.gmd_error, { Color.WHITE }, Color.RED) { ERROR(GoogleMaterial.Icon.gmd_error, { Color.WHITE }, Color.RED) {
override fun onClick(activity: ImageActivity) { override fun onClick(activity: ImageActivity) {
val err = val err =
activity.errorRef?.takeIf { it !is FileNotFoundException && it.message != "Image failed to decode using JPEG decoder" } activity.errorRef?.takeIf {
?: return it !is FileNotFoundException && it.message != "Image failed to decode using JPEG decoder"
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()
}
})
} }
?: 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)
} }

View File

@ -55,190 +55,174 @@ import com.pitchedapps.frost.utils.launchNewTask
import com.pitchedapps.frost.utils.loadAssets import com.pitchedapps.frost.utils.loadAssets
import com.pitchedapps.frost.widgets.NotificationWidget import com.pitchedapps.frost.widgets.NotificationWidget
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject
import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import javax.inject.Inject
/** /**
* Created by Allan Wang on 2017-07-25. * Created by Allan Wang on 2017-07-25.
* *
* A beautiful intro activity * A beautiful intro activity Phone showcases are drawn via layers
* Phone showcases are drawn via layers
*/ */
@AndroidEntryPoint @AndroidEntryPoint
class IntroActivity : class IntroActivity : KauBaseActivity(), ViewPager.PageTransformer, ViewPager.OnPageChangeListener {
KauBaseActivity(),
ViewPager.PageTransformer,
ViewPager.OnPageChangeListener {
@Inject @Inject lateinit var prefs: Prefs
lateinit var prefs: Prefs
@Inject @Inject lateinit var themeProvider: ThemeProvider
lateinit var themeProvider: ThemeProvider
@Inject @Inject lateinit var activityThemer: ActivityThemer
lateinit var activityThemer: ActivityThemer
lateinit var binding: ActivityIntroBinding lateinit var binding: ActivityIntroBinding
private var barHasNext = true private var barHasNext = true
private val fragments by lazyUi { private val fragments by lazyUi {
listOf( listOf(
IntroFragmentWelcome(), IntroFragmentWelcome(),
IntroFragmentTheme(), IntroFragmentTheme(),
IntroAccountFragment(), IntroAccountFragment(),
IntroTabTouchFragment(), IntroTabTouchFragment(),
IntroTabContextFragment(), IntroTabContextFragment(),
IntroFragmentEnd() 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)
} }
indicator.setViewPager(viewpager)
override fun onCreate(savedInstanceState: Bundle?) { next.setIcon(GoogleMaterial.Icon.gmd_navigate_next)
super.onCreate(savedInstanceState) next.setOnClickListener {
binding = ActivityIntroBinding.inflate(layoutInflater) if (barHasNext) viewpager.setCurrentItem(viewpager.currentItem + 1, true)
setContentView(binding.root) else finish(next.x + next.pivotX, next.y + next.pivotY)
binding.init()
} }
skip.setOnClickListener { finish() }
ripple.set(themeProvider.bgColor)
theme()
}
private fun ActivityIntroBinding.init() { fun theme() {
viewpager.apply { statusBarColor = themeProvider.headerColor
setPageTransformer(true, this@IntroActivity) navigationBarColor = themeProvider.headerColor
addOnPageChangeListener(this@IntroActivity) with(binding) {
adapter = IntroPageAdapter(supportFragmentManager, fragments) skip.setTextColor(themeProvider.textColor)
} next.imageTintList = ColorStateList.valueOf(themeProvider.textColor)
indicator.setViewPager(viewpager) indicator.setColour(themeProvider.textColor)
next.setIcon(GoogleMaterial.Icon.gmd_navigate_next) indicator.invalidate()
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()
} }
fragments.forEach { it.themeFragment() }
activityThemer.setFrostTheme(forceTransparent = true)
}
fun theme() { /**
statusBarColor = themeProvider.headerColor * Transformations are mainly handled on a per view basis This makes the first fragment fade out
navigationBarColor = themeProvider.headerColor * as the second fragment comes in All fragments are locked in position
with(binding) { */
skip.setTextColor(themeProvider.textColor) override fun transformPage(page: View, position: Float) {
next.imageTintList = ColorStateList.valueOf(themeProvider.textColor) // only apply to adjacent pages
indicator.setColour(themeProvider.textColor) if ((position < 0 && position > -1) || (position > 0 && position < 1)) {
indicator.invalidate() val pageWidth = page.width
} val translateValue = position * -pageWidth
fragments.forEach { it.themeFragment() } page.translationX = (if (translateValue > -pageWidth) translateValue else 0f)
activityThemer.setFrostTheme(forceTransparent = true) page.alpha = if (position < 0) 1 + position else 1f
} else {
page.alpha = 1f
page.translationX = 0f
} }
}
/** fun finish(x: Float, y: Float) {
* Transformations are mainly handled on a per view basis val blue = color(R.color.facebook_blue)
* This makes the first fragment fade out as the second fragment comes in window.setFlags(
* All fragments are locked in position WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE,
*/ WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE
override fun transformPage(page: View, position: Float) { )
// only apply to adjacent pages binding.ripple.ripple(blue, x, y, 600) { postDelayed(1000) { finish() } }
if ((position < 0 && position > -1) || (position > 0 && position < 1)) { val lastView: View? = fragments.last().view
val pageWidth = page.width arrayOf<View?>(
val translateValue = position * -pageWidth binding.skip,
page.translationX = (if (translateValue > -pageWidth) translateValue else 0f) binding.indicator,
page.alpha = if (position < 0) 1 + position else 1f binding.next,
} else { lastView?.findViewById(R.id.intro_title),
page.alpha = 1f lastView?.findViewById(R.id.intro_desc)
page.translationX = 0f )
.forEach { it?.animate()?.alpha(0f)?.setDuration(600)?.start() }
if (themeProvider.textColor != Color.WHITE) {
val f = lastView?.findViewById<ImageView>(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) {
fun finish(x: Float, y: Float) { ValueAnimator.ofFloat(0f, 1f).apply {
val blue = color(R.color.facebook_blue) addUpdateListener {
window.setFlags( val c = themeProvider.headerColor.blendWith(blue, it.animatedValue as Float)
WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE, statusBarColor = c
WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE navigationBarColor = c
)
binding.ripple.ripple(blue, x, y, 600) {
postDelayed(1000) { finish() }
}
val lastView: View? = fragments.last().view
arrayOf<View?>(
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<ImageView>(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()
}
} }
duration = 600
start()
}
} }
}
override fun finish() { override fun finish() {
launch(NonCancellable) { launch(NonCancellable) {
loadAssets(themeProvider) loadAssets(themeProvider)
NotificationWidget.forceUpdate(this@IntroActivity) NotificationWidget.forceUpdate(this@IntroActivity)
launchNewTask<MainActivity>(cookies(), false) launchNewTask<MainActivity>(cookies(), false)
super.finish() super.finish()
}
} }
}
override fun onBackPressed() { override fun onBackPressed() {
with(binding) { with(binding) {
if (viewpager.currentItem > 0) viewpager.setCurrentItem(viewpager.currentItem - 1, true) if (viewpager.currentItem > 0) viewpager.setCurrentItem(viewpager.currentItem - 1, true)
else finish() 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) { class IntroPageAdapter(fm: FragmentManager, private val fragments: List<BaseIntroFragment>) :
fragments[position].onPageScrolled(positionOffset) FragmentPagerAdapter(fm) {
if (position + 1 < fragments.size)
fragments[position + 1].onPageScrolled(positionOffset - 1)
}
override fun onPageSelected(position: Int) { override fun getItem(position: Int): Fragment = fragments[position]
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)
}
class IntroPageAdapter(fm: FragmentManager, private val fragments: List<BaseIntroFragment>) : override fun getCount(): Int = fragments.size
FragmentPagerAdapter(fm) { }
override fun getItem(position: Int): Fragment = fragments[position]
override fun getCount(): Int = fragments.size
}
} }

View File

@ -49,6 +49,9 @@ import com.pitchedapps.frost.web.FrostEmitter
import com.pitchedapps.frost.web.LoginWebView import com.pitchedapps.frost.web.LoginWebView
import com.pitchedapps.frost.web.asFrostEmitter import com.pitchedapps.frost.web.asFrostEmitter
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import java.net.UnknownHostException
import javax.inject.Inject
import kotlin.coroutines.resume
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async import kotlinx.coroutines.async
import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.channels.BufferOverflow
@ -63,165 +66,157 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeout 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 @AndroidEntryPoint
class LoginActivity : BaseActivity() { class LoginActivity : BaseActivity() {
@Inject @Inject lateinit var cookieDao: CookieDao
lateinit var cookieDao: CookieDao
private val toolbar: Toolbar by bindView(R.id.toolbar) private val toolbar: Toolbar by bindView(R.id.toolbar)
private val web: LoginWebView by bindView(R.id.login_webview) private val web: LoginWebView by bindView(R.id.login_webview)
private val swipeRefresh: SwipeRefreshLayout by bindView(R.id.swipe_refresh) private val swipeRefresh: SwipeRefreshLayout by bindView(R.id.swipe_refresh)
private val textview: AppCompatTextView by bindView(R.id.textview) private val textview: AppCompatTextView by bindView(R.id.textview)
private val profile: ImageView by bindView(R.id.profile) private val profile: ImageView by bindView(R.id.profile)
private lateinit var profileLoader: RequestManager private lateinit var profileLoader: RequestManager
private val refreshMutableFlow = MutableSharedFlow<Boolean>( private val refreshMutableFlow =
extraBufferCapacity = 10, MutableSharedFlow<Boolean>(
onBufferOverflow = BufferOverflow.DROP_OLDEST extraBufferCapacity = 10,
onBufferOverflow = BufferOverflow.DROP_OLDEST
) )
private val refreshFlow: SharedFlow<Boolean> = refreshMutableFlow.asSharedFlow() private val refreshFlow: SharedFlow<Boolean> = refreshMutableFlow.asSharedFlow()
private val refreshEmit: FrostEmitter<Boolean> = refreshMutableFlow.asFrostEmitter() private val refreshEmit: FrostEmitter<Boolean> = refreshMutableFlow.asFrostEmitter()
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
setContentView(R.layout.activity_login) setContentView(R.layout.activity_login)
setSupportActionBar(toolbar) setSupportActionBar(toolbar)
setTitle(R.string.kau_login) setTitle(R.string.kau_login)
activityThemer.setFrostColors { activityThemer.setFrostColors { toolbar(toolbar) }
toolbar(toolbar) profileLoader = GlideApp.with(profile)
}
profileLoader = GlideApp.with(profile)
refreshFlow refreshFlow.distinctUntilChanged().onEach { swipeRefresh.isRefreshing = it }.launchIn(this)
.distinctUntilChanged()
.onEach { swipeRefresh.isRefreshing = it }
.launchIn(this)
launch { launch {
val cookie = web.loadLogin { refresh(it != 100) }.await() val cookie = web.loadLogin { refresh(it != 100) }.await()
L.d { "Login found" } L.d { "Login found" }
fbCookie.save(cookie.id) fbCookie.save(cookie.id)
webFadeOut() webFadeOut()
profile.fadeIn() profile.fadeIn()
loadInfo(cookie) 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 -> textview.text = String.format(getString(R.string.welcome), name ?: "")
web.fadeOut { cont.resume(Unit) } 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<IntroActivity>(cookies, true)
else launchNewTask<MainActivity>(cookies, true)
}
private suspend fun loadInfo(cookie: CookieEntity): Unit = withMainContext { private suspend fun loadProfile(id: Long): Boolean = withMainContext {
refresh(true) suspendCancellableCoroutine<Boolean> { cont ->
profileLoader
val imageDeferred = async { loadProfile(cookie.id) } .load(profilePictureUrl(id))
val nameDeferred = async { loadUsername(cookie) } .transform(FrostGlide.circleCrop)
.listener(
val name: String? = nameDeferred.await() object : RequestListener<Drawable> {
val foundImage: Boolean = imageDeferred.await() override fun onResourceReady(
resource: Drawable?,
L._d { "Logged in and received data" } model: Any?,
refresh(false) target: Target<Drawable>?,
dataSource: DataSource?,
if (!foundImage) { isFirstResource: Boolean
L.e { "Could not get profile photo; Invalid userId?" } ): Boolean {
L._i { cookie } cont.resume(true)
} return false
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<IntroActivity>(cookies, true)
else
launchNewTask<MainActivity>(cookies, true)
}
private suspend fun loadProfile(id: Long): Boolean = withMainContext {
suspendCancellableCoroutine<Boolean> { cont ->
profileLoader.load(profilePictureUrl(id))
.transform(FrostGlide.circleCrop).listener(object : RequestListener<Drawable> {
override fun onResourceReady(
resource: Drawable?,
model: Any?,
target: Target<Drawable>?,
dataSource: DataSource?,
isFirstResource: Boolean
): Boolean {
cont.resume(true)
return false
}
override fun onLoadFailed(
e: GlideException?,
model: Any?,
target: Target<Drawable>?,
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()
} }
override fun onLoadFailed(
e: GlideException?,
model: Any?,
target: Target<Drawable>?,
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) { } catch (e: Exception) {
if (e !is UnknownHostException) if (e !is UnknownHostException) e.logFrostEvent("Fetch username failed")
e.logFrostEvent("Fetch username failed") null
null
} }
if (result != null) { if (result != null) {
cookieDao.save(cookie.copy(name = result)) cookieDao.save(cookie.copy(name = result))
return@withContext result return@withContext result
} }
return@withContext cookie.name return@withContext cookie.name
} }
override fun backConsumer(): Boolean { override fun backConsumer(): Boolean {
if (web.canGoBack()) { if (web.canGoBack()) {
web.goBack() web.goBack()
return true return true
}
return false
} }
return false
}
override fun onResume() { override fun onResume() {
super.onResume() super.onResume()
web.resumeTimers() web.resumeTimers()
} }
override fun onPause() { override fun onPause() {
web.pauseTimers() web.pauseTimers()
super.onPause() super.onPause()
} }
} }

View File

@ -40,93 +40,92 @@ import kotlinx.coroutines.flow.onEach
class MainActivity : BaseMainActivity() { class MainActivity : BaseMainActivity() {
private val fragmentMutableFlow = MutableSharedFlow<Int>( private val fragmentMutableFlow =
extraBufferCapacity = 10, MutableSharedFlow<Int>(extraBufferCapacity = 10, onBufferOverflow = BufferOverflow.DROP_OLDEST)
onBufferOverflow = BufferOverflow.DROP_OLDEST override val fragmentFlow: SharedFlow<Int> = fragmentMutableFlow.asSharedFlow()
) override val fragmentEmit: FrostEmitter<Int> = fragmentMutableFlow.asFrostEmitter()
override val fragmentFlow: SharedFlow<Int> = fragmentMutableFlow.asSharedFlow()
override val fragmentEmit: FrostEmitter<Int> = fragmentMutableFlow.asFrostEmitter()
private val headerMutableFlow = MutableStateFlow("") private val headerMutableFlow = MutableStateFlow("")
override val headerFlow: SharedFlow<String> = headerMutableFlow.asSharedFlow() override val headerFlow: SharedFlow<String> = headerMutableFlow.asSharedFlow()
override val headerEmit: FrostEmitter<String> = headerMutableFlow.asFrostEmitter() override val headerEmit: FrostEmitter<String> = headerMutableFlow.asFrostEmitter()
override fun onNestedCreate(savedInstanceState: Bundle?) { override fun onNestedCreate(savedInstanceState: Bundle?) {
with(contentBinding) { with(contentBinding) {
setupTabs() setupTabs()
setupViewPager() 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() { override fun onPageScrolled(
viewpager.addOnPageChangeListener(object : ViewPager.SimpleOnPageChangeListener() { position: Int,
override fun onPageSelected(position: Int) { positionOffset: Float,
super.onPageSelected(position) positionOffsetPixels: Int
if (lastPosition == position) { ) {
return super.onPageScrolled(position, positionOffset, positionOffsetPixels)
} val delta = positionOffset * (SELECTED_TAB_ALPHA - UNSELECTED_TAB_ALPHA)
if (lastPosition != -1) { tabsForEachView { tabPosition, view ->
fragmentEmit(-(lastPosition + 1)) view.setAllAlpha(
} when (tabPosition) {
fragmentEmit(position) position -> SELECTED_TAB_ALPHA - delta
lastPosition = position position + 1 -> UNSELECTED_TAB_ALPHA + delta
} else -> UNSELECTED_TAB_ALPHA
}
)
}
}
}
)
}
override fun onPageScrolled( private fun ActivityMainContentBinding.setupTabs() {
position: Int, viewpager.addOnPageChangeListener(TabLayout.TabLayoutOnPageChangeListener(tabs))
positionOffset: Float, tabs.addOnTabSelectedListener(
positionOffsetPixels: Int object : TabLayout.ViewPagerOnTabSelectedListener(viewpager) {
) { override fun onTabReselected(tab: TabLayout.Tab) {
super.onPageScrolled(position, positionOffset, positionOffsetPixels) super.onTabReselected(tab)
val delta = positionOffset * (SELECTED_TAB_ALPHA - UNSELECTED_TAB_ALPHA) currentFragment?.onTabClick()
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() { override fun onTabSelected(tab: TabLayout.Tab) {
viewpager.addOnPageChangeListener(TabLayout.TabLayoutOnPageChangeListener(tabs)) super.onTabSelected(tab)
tabs.addOnTabSelectedListener(object : TabLayout.ViewPagerOnTabSelectedListener(viewpager) { (tab.customView as BadgedIcon).badgeText = null
override fun onTabReselected(tab: TabLayout.Tab) { }
super.onTabReselected(tab) }
currentFragment?.onTabClick() )
} headerFlow
.filter { it.isNotBlank() }
override fun onTabSelected(tab: TabLayout.Tab) { .mapNotNull { html ->
super.onTabSelected(tab) BadgeParser.parseFromData(cookie = fbCookie.webCookie, text = html)?.data
(tab.customView as BadgedIcon).badgeText = null }
} .distinctUntilChanged()
}) .flowOn(Dispatchers.IO)
headerFlow .onEach { data ->
.filter { it.isNotBlank() } L.v { "Badges $data" }
.mapNotNull { html -> tabsForEachView { _, view ->
BadgeParser.parseFromData( when (view.iicon) {
cookie = fbCookie.webCookie, FbItem.FEED.icon -> view.badgeText = data.feed
text = html FbItem.FRIENDS.icon -> view.badgeText = data.friends
)?.data FbItem.MESSAGES.icon -> view.badgeText = data.messages
} FbItem.NOTIFICATIONS.icon -> view.badgeText = data.notifications
.distinctUntilChanged() }
.flowOn(Dispatchers.IO) }
.onEach { data -> }
L.v { "Badges $data" } .flowOn(Dispatchers.Main)
tabsForEachView { _, view -> .launchIn(this@MainActivity)
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)
}
} }

View File

@ -32,43 +32,44 @@ import com.pitchedapps.frost.utils.launchNewTask
import com.pitchedapps.frost.views.AccountItem import com.pitchedapps.frost.views.AccountItem
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
/** /** Created by Allan Wang on 2017-06-04. */
* Created by Allan Wang on 2017-06-04.
*/
class SelectorActivity : BaseActivity() { class SelectorActivity : BaseActivity() {
val recycler: RecyclerView by bindView(R.id.selector_recycler) val recycler: RecyclerView by bindView(R.id.selector_recycler)
val adapter = FastItemAdapter<AccountItem>() val adapter = FastItemAdapter<AccountItem>()
val text: AppCompatTextView by bindView(R.id.text_select_account) val text: AppCompatTextView by bindView(R.id.text_select_account)
val container: ConstraintLayout by bindView(R.id.container) val container: ConstraintLayout by bindView(R.id.container)
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
setContentView(R.layout.activity_selector) setContentView(R.layout.activity_selector)
recycler.layoutManager = GridLayoutManager(this, 2) recycler.layoutManager = GridLayoutManager(this, 2)
recycler.adapter = adapter recycler.adapter = adapter
adapter.add(cookies().map { AccountItem(it, themeProvider) }) adapter.add(cookies().map { AccountItem(it, themeProvider) })
adapter.add(AccountItem(null, themeProvider)) // add account adapter.add(AccountItem(null, themeProvider)) // add account
adapter.addEventHook(object : ClickEventHook<AccountItem>() { adapter.addEventHook(
override fun onBind(viewHolder: RecyclerView.ViewHolder): View? = object : ClickEventHook<AccountItem>() {
(viewHolder as? AccountItem.ViewHolder)?.itemView override fun onBind(viewHolder: RecyclerView.ViewHolder): View? =
(viewHolder as? AccountItem.ViewHolder)?.itemView
override fun onClick( override fun onClick(
v: View, v: View,
position: Int, position: Int,
fastAdapter: FastAdapter<AccountItem>, fastAdapter: FastAdapter<AccountItem>,
item: AccountItem item: AccountItem
) { ) {
if (item.cookie == null) this@SelectorActivity.launchNewTask<LoginActivity>() if (item.cookie == null) this@SelectorActivity.launchNewTask<LoginActivity>()
else launch { else
fbCookie.switchUser(item.cookie) launch {
launchNewTask<MainActivity>(cookies()) fbCookie.switchUser(item.cookie)
} launchNewTask<MainActivity>(cookies())
} }
})
activityThemer.setFrostColors {
text(text)
background(container)
} }
}
)
activityThemer.setFrostColors {
text(text)
background(container)
} }
}
} }

View File

@ -59,225 +59,206 @@ import com.pitchedapps.frost.utils.frostNavigationBar
import com.pitchedapps.frost.utils.launchNewTask import com.pitchedapps.frost.utils.launchNewTask
import com.pitchedapps.frost.utils.loadAssets import com.pitchedapps.frost.utils.loadAssets
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject
import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.launch 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 @AndroidEntryPoint
class SettingsActivity : KPrefActivity() { class SettingsActivity : KPrefActivity() {
@Inject @Inject lateinit var fbCookie: FbCookie
lateinit var fbCookie: FbCookie
@Inject @Inject lateinit var prefs: Prefs
lateinit var prefs: Prefs
@Inject @Inject lateinit var themeProvider: ThemeProvider
lateinit var themeProvider: ThemeProvider
@Inject @Inject lateinit var notifDao: NotificationDao
lateinit var notifDao: NotificationDao
@Inject @Inject lateinit var activityThemer: ActivityThemer
lateinit var activityThemer: ActivityThemer
private var resultFlag = Activity.RESULT_CANCELED private var resultFlag = Activity.RESULT_CANCELED
companion object { companion object {
private const val REQUEST_RINGTONE = 0b10111 shl 5 private const val REQUEST_RINGTONE = 0b10111 shl 5
const val REQUEST_NOTIFICATION_RINGTONE = REQUEST_RINGTONE or 1 const val REQUEST_NOTIFICATION_RINGTONE = REQUEST_RINGTONE or 1
const val REQUEST_MESSAGE_RINGTONE = REQUEST_RINGTONE or 2 const val REQUEST_MESSAGE_RINGTONE = REQUEST_RINGTONE or 2
const val ACTIVITY_REQUEST_TABS = 29 const val ACTIVITY_REQUEST_TABS = 29
const val ACTIVITY_REQUEST_DEBUG = 53 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<Uri>(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?) { subItems(R.string.behaviour, getBehaviourPrefs()) {
super.onActivityResult(requestCode, resultCode, data) descRes = R.string.behaviour_desc
if (fetchRingtone(requestCode, resultCode, data)) return iicon = GoogleMaterial.Icon.gmd_trending_up
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.newsfeed, getFeedPrefs()) {
* Fetch ringtone and save uri descRes = R.string.newsfeed_desc
* Returns [true] if consumed, [false] otherwise iicon = CommunityMaterial.Icon3.cmd_newspaper
*/
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<Uri>(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 = { subItems(R.string.notifications, getNotificationPrefs()) {
textColor = { themeProvider.textColor } descRes = R.string.notifications_desc
accentColor = { themeProvider.accentColor } iicon = GoogleMaterial.Icon.gmd_notifications
} }
override fun onCreateKPrefs(savedInstanceState: Bundle?): KPrefAdapterBuilder.() -> Unit = { subItems(R.string.security, getSecurityPrefs()) {
subItems(R.string.appearance, getAppearancePrefs()) { descRes = R.string.security_desc
descRes = R.string.appearance_desc iicon = GoogleMaterial.Icon.gmd_lock
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<AboutActivity>(
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<IntroActivity>(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 }
}
} }
fun setFrostResult(flag: Int) { // subItems(R.string.network, getNetworkPrefs()) {
resultFlag = resultFlag or flag // descRes = R.string.network_desc
} // iicon = GoogleMaterial.Icon.gmd_network_cell
// }
fun shouldRestartMain() { // todo add donation?
setFrostResult(REQUEST_RESTART)
}
fun shouldRefreshMain() { plainText(R.string.about_frost) {
setFrostResult(REQUEST_REFRESH) descRes = R.string.about_frost_desc
} iicon = GoogleMaterial.Icon.gmd_info
onClick = {
@SuppressLint("MissingSuperCall") startActivityForResult<AboutActivity>(
override fun onCreate(savedInstanceState: Bundle?) { 9,
super.onCreate(savedInstanceState) bundleBuilder = { withSceneTransitionAnimation(this@SettingsActivity) }
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() { plainText(R.string.help_translate) {
if (!super.backPress()) { descRes = R.string.help_translate_desc
setResult(resultFlag) iicon = GoogleMaterial.Icon.gmd_translate
launch(NonCancellable) { onClick = { startLink(R.string.translation_url) }
loadAssets(themeProvider)
finishSlideOut()
}
}
} }
override fun onCreateOptionsMenu(menu: Menu): Boolean { plainText(R.string.replay_intro) {
menuInflater.inflate(R.menu.menu_settings, menu) iicon = GoogleMaterial.Icon.gmd_replay
toolbar.tint(themeProvider.iconColor) onClick = { launchNewTask<IntroActivity>(cookies(), true) }
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 { subItems(R.string.experimental, getExperimentalPrefs()) {
when (item.itemId) { descRes = R.string.experimental_desc
R.id.action_github -> startLink(R.string.github_url) iicon = CommunityMaterial.Icon2.cmd_flask_outline
R.id.action_changelog -> frostChangelog()
else -> return super.onOptionsItemSelected(item)
}
return true
} }
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
}
} }

View File

@ -43,119 +43,116 @@ import com.pitchedapps.frost.facebook.FbItem
import com.pitchedapps.frost.iitems.TabIItem import com.pitchedapps.frost.iitems.TabIItem
import com.pitchedapps.frost.utils.L import com.pitchedapps.frost.utils.L
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.launch
import java.util.Collections import java.util.Collections
import javax.inject.Inject 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 @AndroidEntryPoint
class TabCustomizerActivity : BaseActivity() { class TabCustomizerActivity : BaseActivity() {
@Inject @Inject lateinit var genericDao: GenericDao
lateinit var genericDao: GenericDao
private val adapter = FastItemAdapter<TabIItem>() private val adapter = FastItemAdapter<TabIItem>()
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?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
binding = ActivityTabCustomizerBinding.inflate(layoutInflater) binding = ActivityTabCustomizerBinding.inflate(layoutInflater)
setContentView(binding.root) setContentView(binding.root)
binding.init() 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() { setResult(Activity.RESULT_CANCELED)
pseudoToolbar.setBackgroundColor(themeProvider.headerColor)
tabRecycler.layoutManager = fabSave.setIcon(GoogleMaterial.Icon.gmd_check, themeProvider.iconColor)
GridLayoutManager(this@TabCustomizerActivity, TAB_COUNT, RecyclerView.VERTICAL, false) fabSave.backgroundTintList = ColorStateList.valueOf(themeProvider.accentColor)
tabRecycler.adapter = adapter fabSave.setOnClickListener {
tabRecycler.setHasFixedSize(true) 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)) private fun View.wobble() = startAnimation(wobble(context))
instructions.setTextColor(themeProvider.textColor)
launch { private fun bindSwapper(adapter: FastItemAdapter<*>, recycler: RecyclerView) {
val tabs = genericDao.getTabs().toMutableList() val dragCallback = TabDragCallback(SimpleDragCallback.ALL, swapper(adapter))
L.d { "Tabs $tabs" } ItemTouchHelper(dragCallback).attachToRecyclerView(recycler)
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) 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 } override fun itemTouchDropped(oldPosition: Int, newPosition: Int) = Unit
}
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
}
} }
private fun View.wobble() = startAnimation(wobble(context)) private class TabDragCallback(directions: Int, itemTouchCallback: ItemTouchCallback) :
SimpleDragCallback(directions, itemTouchCallback) {
private fun bindSwapper(adapter: FastItemAdapter<*>, recycler: RecyclerView) { private var draggingView: TabIItem.ViewHolder? = null
val dragCallback = TabDragCallback(SimpleDragCallback.ALL, swapper(adapter))
ItemTouchHelper(dragCallback).attachToRecyclerView(recycler)
}
private fun swapper(adapter: FastItemAdapter<*>) = object : ItemTouchCallback { override fun onSelectedChanged(viewHolder: RecyclerView.ViewHolder?, actionState: Int) {
override fun itemTouchOnMove(oldPosition: Int, newPosition: Int): Boolean { super.onSelectedChanged(viewHolder, actionState)
Collections.swap(adapter.adapterItems, oldPosition, newPosition) when (actionState) {
adapter.notifyAdapterDataSetChanged() ItemTouchHelper.ACTION_STATE_DRAG -> {
return true (viewHolder as? TabIItem.ViewHolder)?.apply {
draggingView = this
itemView.animate().scaleXY(1.3f)
text.animate().alpha(0f)
}
} }
ItemTouchHelper.ACTION_STATE_IDLE -> {
override fun itemTouchDropped(oldPosition: Int, newPosition: Int) = Unit draggingView?.apply {
} itemView.animate().scaleXY(1f)
text.animate().alpha(1f)
private class TabDragCallback( }
directions: Int, draggingView = null
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
}
}
} }
}
} }
}
} }

View File

@ -64,6 +64,7 @@ import com.pitchedapps.frost.views.FrostContentWeb
import com.pitchedapps.frost.views.FrostVideoViewer import com.pitchedapps.frost.views.FrostVideoViewer
import com.pitchedapps.frost.views.FrostWebView import com.pitchedapps.frost.views.FrostWebView
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
@ -71,7 +72,6 @@ import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.take import kotlinx.coroutines.flow.take
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import javax.inject.Inject
/** /**
* Created by Allan Wang on 2017-06-01. * 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 * Used by notifications. Unlike the other overlays, this runs as a singleInstance Going back will
* Going back will bring you back to the previous app * bring you back to the previous app
*/ */
class FrostWebActivity : WebOverlayActivityBase() { class FrostWebActivity : WebOverlayActivityBase() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
val requiresAction = !parseActionSend() val requiresAction = !parseActionSend()
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
if (requiresAction) { if (requiresAction) {
/* /*
* Signifies that we need to let the user know of a bad url * Signifies that we need to let the user know of a bad url
* We will subscribe to the load cycle once, * We will subscribe to the load cycle once,
* and pop a dialog giving the user the option to copy the shared text * and pop a dialog giving the user the option to copy the shared text
*/ */
content.scope.launch(Dispatchers.IO) { content.scope.launch(Dispatchers.IO) {
content.refreshFlow.take(1).collect() content.refreshFlow.take(1).collect()
withMainContext { withMainContext {
materialDialog { materialDialog {
title(R.string.invalid_share_url) title(R.string.invalid_share_url)
message(R.string.invalid_share_url_desc) message(R.string.invalid_share_url_desc)
} }
}
}
} }
}
} }
}
/** /**
* Attempts to parse the action url * Attempts to parse the action url Returns [true] if no action exists or if the action has been
* 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 * consumed, [false] if we need to notify the user of a bad action
*/ */
private fun parseActionSend(): Boolean { private fun parseActionSend(): Boolean {
if (intent.action != Intent.ACTION_SEND || intent.type != "text/plain") return true if (intent.action != Intent.ACTION_SEND || intent.type != "text/plain") return true
val text = intent.getStringExtra(Intent.EXTRA_TEXT) ?: return true val text = intent.getStringExtra(Intent.EXTRA_TEXT) ?: return true
val url = text.toHttpUrlOrNull()?.toString() val url = text.toHttpUrlOrNull()?.toString()
return if (url == null) { return if (url == null) {
L.i { "Attempted to share a non-url" } L.i { "Attempted to share a non-url" }
L._i { "Shared text: $text" } L._i { "Shared text: $text" }
copyToClipboard(text, "Text to Share", showToast = false) copyToClipboard(text, "Text to Share", showToast = false)
intent.putExtra(ARG_URL, FbItem.FEED.url) intent.putExtra(ARG_URL, FbItem.FEED.url)
false false
} else { } else {
L.i { "Sharing url through overlay" } L.i { "Sharing url through overlay" }
L._i { "Url: $url" } L._i { "Url: $url" }
intent.putExtra(ARG_URL, "${FB_URL_BASE}sharer/sharer.php?u=$url") intent.putExtra(ARG_URL, "${FB_URL_BASE}sharer/sharer.php?u=$url")
true true
}
} }
}
} }
/** /**
* Variant that forces a mobile user agent. This is largely internal, * Variant that forces a mobile user agent. This is largely internal, and is only necessary when we
* and is only necessary when we are launching from an existing [WebOverlayActivityBase] * are launching from an existing [WebOverlayActivityBase]
*/ */
class WebOverlayMobileActivity : WebOverlayActivityBase(USER_AGENT_MOBILE_CONST) class WebOverlayMobileActivity : WebOverlayActivityBase(USER_AGENT_MOBILE_CONST)
/** /**
* Variant that forces a desktop user agent. This is largely internal, * Variant that forces a desktop user agent. This is largely internal, and is only necessary when we
* and is only necessary when we are launching from an existing [WebOverlayActivityBase] * are launching from an existing [WebOverlayActivityBase]
*/ */
class WebOverlayDesktopActivity : WebOverlayActivityBase(USER_AGENT_DESKTOP_CONST) 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() class WebOverlayActivity : WebOverlayActivityBase()
@AndroidEntryPoint @AndroidEntryPoint
abstract class WebOverlayActivityBase(private val userAgent: String = USER_AGENT) : abstract class WebOverlayActivityBase(private val userAgent: String = USER_AGENT) :
BaseActivity(), BaseActivity(), FrostContentContainer, VideoViewHolder {
FrostContentContainer,
VideoViewHolder {
override val frameWrapper: FrameLayout by bindView(R.id.frame_wrapper) override val frameWrapper: FrameLayout by bindView(R.id.frame_wrapper)
val toolbar: Toolbar by bindView(R.id.overlay_toolbar) val toolbar: Toolbar by bindView(R.id.overlay_toolbar)
val content: FrostContentWeb by bindView(R.id.frost_content_web) val content: FrostContentWeb by bindView(R.id.frost_content_web)
val web: FrostWebView val web: FrostWebView
get() = content.coreView get() = content.coreView
private val coordinator: CoordinatorLayout by bindView(R.id.overlay_main_content) private val coordinator: CoordinatorLayout by bindView(R.id.overlay_main_content)
@Inject @Inject lateinit var webFileChooser: WebFileChooser
lateinit var webFileChooser: WebFileChooser
private inline val urlTest: String? private inline val urlTest: String?
get() = intent.getStringExtra(ARG_URL) ?: intent.dataString get() = intent.getStringExtra(ARG_URL) ?: intent.dataString
lateinit var swipeBack: SwipeBackContract lateinit var swipeBack: SwipeBackContract
/** /** Nonnull variant; verify by checking [urlTest] */
* Nonnull variant; verify by checking [urlTest] override val baseUrl: String
*/ get() = urlTest!!.formattedFbUrl
override val baseUrl: String
get() = urlTest!!.formattedFbUrl
override val baseEnum: FbItem? = null override val baseEnum: FbItem? = null
private inline val userId: Long private inline val userId: Long
get() = intent.getLongExtra(ARG_USER_ID, prefs.userId) get() = intent.getLongExtra(ARG_USER_ID, prefs.userId)
private val overlayContext: OverlayContext? private val overlayContext: OverlayContext?
get() = OverlayContext[intent.extras] get() = OverlayContext[intent.extras]
override fun setTitle(title: String) { override fun setTitle(title: String) {
toolbar.title = title 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?) { activityThemer.setFrostColors {
super.onCreate(savedInstanceState) toolbar(toolbar)
if (urlTest == null) { themeWindow = false
L.e { "Empty link on web overlay" } }
toast(R.string.null_url_overlay) coordinator.setBackgroundColor(themeProvider.bgColor.withAlpha(255))
finish()
return 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) authDefer.await()
setSupportActionBar(toolbar) reloadBase(true)
supportActionBar?.setDisplayShowHomeEnabled(true) if (prefs.firstWebOverlay) {
supportActionBar?.setDisplayHomeAsUpEnabled(true) coordinator.frostSnackbar(R.string.web_overlay_swipe_hint, themeProvider) {
toolbar.navigationIcon = duration = BaseTransientBottomBar.LENGTH_INDEFINITE
GoogleMaterial.Icon.gmd_close.toDrawable(this, 16, themeProvider.iconColor) setAction(R.string.kau_got_it) { dismiss() }
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
} }
}
} }
/** swipeBack = kauSwipeOnCreate {
* Manage url loadings if (!prefs.overlayFullScreenSwipe) edgeSize = 20.dpToPx
* This is usually only called when multiple listeners are added and inject the same url transitionSystemBars = false
* 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)
}
} }
}
override fun backConsumer(): Boolean { /**
if (!web.onBackPressed()) * Manage url loadings This is usually only called when multiple listeners are added and inject
finishSlideOut() * the same url We will avoid reloading if the url is the same
return true */
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)
} }
}
/** override fun backConsumer(): Boolean {
* Our theme for the overlay should be fully opaque if (!web.onBackPressed()) finishSlideOut()
*/ return true
fun theme() { }
val opaqueAccent = themeProvider.headerColor.withAlpha(255)
statusBarColor = opaqueAccent.darken() /** Our theme for the overlay should be fully opaque */
navigationBarColor = opaqueAccent fun theme() {
toolbar.setBackgroundColor(opaqueAccent) val opaqueAccent = themeProvider.headerColor.withAlpha(255)
toolbar.setTitleTextColor(themeProvider.iconColor) statusBarColor = opaqueAccent.darken()
coordinator.setBackgroundColor(themeProvider.bgColor.withAlpha(255)) navigationBarColor = opaqueAccent
toolbar.overflowIcon?.setTint(themeProvider.iconColor) 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() * Video Contract
} * ----------------------------------------------------
*/
override fun onPause() { override var videoViewer: FrostVideoViewer? = null
web.pauseTimers() override val lowerVideoPadding: PointF = PointF(0f, 0f)
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)
} }

View File

@ -22,24 +22,22 @@ import com.pitchedapps.frost.web.FrostEmitter
import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.SharedFlow
interface MainActivityContract : MainFabContract { interface MainActivityContract : MainFabContract {
val fragmentFlow: SharedFlow<Int> val fragmentFlow: SharedFlow<Int>
val fragmentEmit: FrostEmitter<Int> val fragmentEmit: FrostEmitter<Int>
val headerFlow: SharedFlow<String> val headerFlow: SharedFlow<String>
val headerEmit: FrostEmitter<String> val headerEmit: FrostEmitter<String>
fun setTitle(res: Int) fun setTitle(res: Int)
fun setTitle(text: CharSequence) fun setTitle(text: CharSequence)
/** /** Available on all threads */
* Available on all threads fun collapseAppBar()
*/
fun collapseAppBar()
fun reloadFragment(fragment: BaseFragment) fun reloadFragment(fragment: BaseFragment)
} }
interface MainFabContract { interface MainFabContract {
fun showFab(iicon: IIcon, clickEvent: () -> Unit) fun showFab(iicon: IIcon, clickEvent: () -> Unit)
fun hideFab() fun hideFab()
} }

View File

@ -16,29 +16,18 @@
*/ */
package com.pitchedapps.frost.contracts package com.pitchedapps.frost.contracts
/** /** Functions that will modify the current ui */
* Functions that will modify the current ui
*/
interface DynamicUiContract { interface DynamicUiContract {
/** /** Change all necessary view components to the new theme Also propagate where applicable */
* Change all necessary view components to the new theme fun reloadTheme()
* Also propagate where applicable
*/
fun reloadTheme()
/** /** Change theme without propagation */
* Change theme without propagation fun reloadThemeSelf()
*/
fun reloadThemeSelf()
/** /** Change text size & propagate */
* Change text size & propagate fun reloadTextSize()
*/
fun reloadTextSize()
/** /** Change text size without propagation */
* Change text size without propagation fun reloadTextSizeSelf()
*/
fun reloadTextSizeSelf()
} }

View File

@ -35,70 +35,58 @@ import dagger.hilt.android.components.ActivityComponent
import dagger.hilt.android.scopes.ActivityScoped import dagger.hilt.android.scopes.ActivityScoped
import javax.inject.Inject 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 private const val MEDIA_CHOOSER_RESULT = 67
interface WebFileChooser { interface WebFileChooser {
fun openMediaPicker( fun openMediaPicker(
filePathCallback: ValueCallback<Array<Uri>?>, filePathCallback: ValueCallback<Array<Uri>?>,
fileChooserParams: WebChromeClient.FileChooserParams fileChooserParams: WebChromeClient.FileChooserParams
) )
fun onActivityResultWeb( fun onActivityResultWeb(requestCode: Int, resultCode: Int, intent: Intent?): Boolean
requestCode: Int,
resultCode: Int,
intent: Intent?
): Boolean
} }
class WebFileChooserImpl @Inject internal constructor( class WebFileChooserImpl
private val activity: Activity, @Inject
private val themeProvider: ThemeProvider internal constructor(private val activity: Activity, private val themeProvider: ThemeProvider) :
) : WebFileChooser { WebFileChooser {
private var filePathCallback: ValueCallback<Array<Uri>?>? = null private var filePathCallback: ValueCallback<Array<Uri>?>? = null
override fun openMediaPicker( override fun openMediaPicker(
filePathCallback: ValueCallback<Array<Uri>?>, filePathCallback: ValueCallback<Array<Uri>?>,
fileChooserParams: WebChromeClient.FileChooserParams fileChooserParams: WebChromeClient.FileChooserParams
) { ) {
activity.kauRequestPermissions(PERMISSION_WRITE_EXTERNAL_STORAGE) { granted, _ -> activity.kauRequestPermissions(PERMISSION_WRITE_EXTERNAL_STORAGE) { granted, _ ->
if (!granted) { if (!granted) {
L.d { "Failed to get write permissions" } L.d { "Failed to get write permissions" }
activity.frostSnackbar(R.string.file_chooser_not_found, themeProvider) activity.frostSnackbar(R.string.file_chooser_not_found, themeProvider)
filePathCallback.onReceiveValue(null) filePathCallback.onReceiveValue(null)
return@kauRequestPermissions return@kauRequestPermissions
} }
this.filePathCallback = filePathCallback this.filePathCallback = filePathCallback
val intent = Intent() val intent = Intent()
intent.type = fileChooserParams.acceptTypes.firstOrNull() intent.type = fileChooserParams.acceptTypes.firstOrNull()
intent.action = Intent.ACTION_GET_CONTENT intent.action = Intent.ACTION_GET_CONTENT
activity.startActivityForResult( activity.startActivityForResult(
Intent.createChooser(intent, activity.string(R.string.pick_image)), Intent.createChooser(intent, activity.string(R.string.pick_image)),
MEDIA_CHOOSER_RESULT MEDIA_CHOOSER_RESULT
) )
}
} }
}
override fun onActivityResultWeb( override fun onActivityResultWeb(requestCode: Int, resultCode: Int, intent: Intent?): Boolean {
requestCode: Int, L.d { "FileChooser On activity results web $requestCode" }
resultCode: Int, if (requestCode != MEDIA_CHOOSER_RESULT) return false
intent: Intent? val data = intent?.data
): Boolean { filePathCallback?.onReceiveValue(if (data != null) arrayOf(data) else null)
L.d { "FileChooser On activity results web $requestCode" } filePathCallback = null
if (requestCode != MEDIA_CHOOSER_RESULT) return false return true
val data = intent?.data }
filePathCallback?.onReceiveValue(if (data != null) arrayOf(data) else null)
filePathCallback = null
return true
}
} }
@Module @Module
@InstallIn(ActivityComponent::class) @InstallIn(ActivityComponent::class)
interface WebFileChooserModule { interface WebFileChooserModule {
@Binds @Binds @ActivityScoped fun webFileChooser(to: WebFileChooserImpl): WebFileChooser
@ActivityScoped
fun webFileChooser(to: WebFileChooserImpl): WebFileChooser
} }

View File

@ -22,159 +22,112 @@ import com.pitchedapps.frost.web.FrostEmitter
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.SharedFlow 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 { interface FrostContentContainer : CoroutineScope {
val baseUrl: String val baseUrl: String
val baseEnum: FbItem? val baseEnum: FbItem?
/** /** Update toolbar title */
* Update toolbar title fun setTitle(title: String)
*/
fun setTitle(title: String)
} }
/** /** Contract for components shared among all content providers */
* Contract for components shared among
* all content providers
*/
interface FrostContentParent : DynamicUiContract { 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 */
* Observable to get data on whether view is refreshing or not val refreshFlow: SharedFlow<Boolean>
*/
val refreshFlow: SharedFlow<Boolean>
val refreshEmit: FrostEmitter<Boolean> val refreshEmit: FrostEmitter<Boolean>
/** /** Observable to get data on refresh progress, with range [0, 100] */
* Observable to get data on refresh progress, with range [0, 100] val progressFlow: SharedFlow<Int>
*/
val progressFlow: SharedFlow<Int>
val progressEmit: FrostEmitter<Int> val progressEmit: FrostEmitter<Int>
/** /** Observable to get new title data (unique values only) */
* Observable to get new title data (unique values only) val titleFlow: SharedFlow<String>
*/
val titleFlow: SharedFlow<String>
val titleEmit: FrostEmitter<String> val titleEmit: FrostEmitter<String>
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 */
* Temporary disable swiping based on action var swipeDisabledByAction: Boolean
*/
var swipeDisabledByAction: Boolean
/** /** Decides if swipe should be allowed for the current page */
* Decides if swipe should be allowed for the current page var swipeAllowedByPage: Boolean
*/
var swipeAllowedByPage: Boolean
/** /**
* Binds the container to self * Binds the container to self this will also handle all future bindings Must be called by
* this will also handle all future bindings * container!
* Must be called by container! */
*/ fun bind(container: FrostContentContainer)
fun bind(container: FrostContentContainer)
/** /** Signal that the contract will not be used again Clean up resources where applicable */
* Signal that the contract will not be used again fun destroy()
* Clean up resources where applicable
*/
fun destroy()
/** /**
* Hook onto the refresh observable for one cycle * Hook onto the refresh observable for one cycle Animate toggles between the fancy ripple and the
* Animate toggles between the fancy ripple and the basic fade * basic fade The cycle only starts on the first load since there may have been another process
* The cycle only starts on the first load since * when this is registered
* 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
* Returns true to proceed with load * advisable to proceed with the load For those cases, we will return false to stop it
* In some cases when the url has not changed, */
* it may not be advisable to proceed with the load fun registerTransition(urlChanged: Boolean, animate: Boolean): Boolean
* 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 { interface FrostContentCore : DynamicUiContract {
val scope: CoroutineScope val scope: CoroutineScope
get() = parent.scope get() = parent.scope
/** /** Reference to parent Bound through calling [FrostContentParent.bind] */
* Reference to parent val parent: FrostContentParent
* Bound through calling [FrostContentParent.bind]
*/
val parent: FrostContentParent
/** /**
* Initializes view through given [container] * Initializes view through given [container]
* *
* The content may be free to extract other data from * The content may be free to extract other data from the container if necessary
* the container if necessary */
*/ fun bind(parent: FrostContentParent, container: FrostContentContainer): View
fun bind(parent: FrostContentParent, container: FrostContentContainer): View
/** /** Call to reload wrapped data */
* Call to reload wrapped data fun reload(animate: Boolean)
*/
fun reload(animate: Boolean)
/** /** Call to reload base data */
* Call to reload base data fun reloadBase(animate: Boolean)
*/
fun reloadBase(animate: Boolean)
/** /** If possible, remove anything in the view stack Applies namely to webviews */
* If possible, remove anything in the view stack fun clearHistory()
* Applies namely to webviews
*/
fun clearHistory()
/** /**
* Should be called when a back press is triggered * Should be called when a back press is triggered Return [true] if consumed, [false] otherwise
* Return [true] if consumed, [false] otherwise */
*/ fun onBackPressed(): Boolean
fun onBackPressed(): Boolean
val currentUrl: String val currentUrl: String
/** /** Condition to help pause certain background resources */
* Condition to help pause certain background resources var active: Boolean
*/
var active: Boolean
/** /** Triggered when view is within viewpager and tab is clicked */
* Triggered when view is within viewpager fun onTabClicked()
* and tab is clicked
*/
fun onTabClicked()
/** /** Signal destruction to release some content manually */
* Signal destruction to release some content manually fun destroy()
*/
fun destroy()
} }

View File

@ -22,23 +22,23 @@ import android.widget.TextView
/** /**
* Created by Allan Wang on 2017-11-07. * Created by Allan Wang on 2017-11-07.
* *
* Should be implemented by all views in [com.pitchedapps.frost.activities.MainActivity] * Should be implemented by all views in [com.pitchedapps.frost.activities.MainActivity] to allow
* to allow for instant view reloading * for instant view reloading
*/ */
interface FrostThemable { interface FrostThemable {
/** /**
* Change all necessary view components to the new theme * Change all necessary view components to the new theme and call whatever other children that
* and call whatever other children that also implement [FrostThemable] * also implement [FrostThemable]
*/ */
fun reloadTheme() fun reloadTheme()
fun setTextColors(color: Int, vararg textViews: TextView?) = fun setTextColors(color: Int, vararg textViews: TextView?) =
themeViews(color, *textViews) { setTextColor(it) } themeViews(color, *textViews) { setTextColor(it) }
fun setBackgrounds(color: Int, vararg views: View?) = fun setBackgrounds(color: Int, vararg views: View?) =
themeViews(color, *views) { setBackgroundColor(it) } themeViews(color, *views) { setBackgroundColor(it) }
fun <T : View> themeViews(color: Int, vararg views: T?, action: T.(Int) -> Unit) = fun <T : View> themeViews(color: Int, vararg views: T?, action: T.(Int) -> Unit) =
views.filterNotNull().forEach { it.action(color) } views.filterNotNull().forEach { it.action(color) }
} }

View File

@ -24,45 +24,38 @@ import com.pitchedapps.frost.utils.L
import com.pitchedapps.frost.views.FrostVideoContainerContract import com.pitchedapps.frost.views.FrostVideoContainerContract
import com.pitchedapps.frost.views.FrostVideoViewer 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 { 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 */
* Create new viewer and reuse existing one fun showVideo(url: String, repeat: Boolean) {
* The url will be formatted upon loading if (videoViewer != null) videoViewer?.setVideo(url, repeat)
*/ else videoViewer = FrostVideoViewer.showVideo(url, repeat, this)
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 override val videoContainer: FrameLayout
get() = frameWrapper get() = frameWrapper
override fun onVideoFinished() { override fun onVideoFinished() {
L.d { "Video view released" } L.d { "Video view released" }
videoViewer = null videoViewer = null
} }
} }
interface FrameWrapper { interface FrameWrapper {
val frameWrapper: FrameLayout val frameWrapper: FrameLayout
fun Activity.setFrameContentView(layoutRes: Int) { fun Activity.setFrameContentView(layoutRes: Int) {
setContentView(R.layout.activity_frame_wrapper) setContentView(R.layout.activity_frame_wrapper)
frameWrapper.inflate(layoutRes, true) frameWrapper.inflate(layoutRes, true)
} }
} }

View File

@ -26,63 +26,52 @@ import androidx.room.Query
import com.pitchedapps.frost.utils.L import com.pitchedapps.frost.utils.L
import kotlinx.android.parcel.Parcelize 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( @Entity(
tableName = "frost_cache", tableName = "frost_cache",
primaryKeys = ["id", "type"], primaryKeys = ["id", "type"],
foreignKeys = [ foreignKeys =
ForeignKey( [
entity = CookieEntity::class, ForeignKey(
parentColumns = ["cookie_id"], entity = CookieEntity::class,
childColumns = ["id"], parentColumns = ["cookie_id"],
onDelete = ForeignKey.CASCADE childColumns = ["id"],
) onDelete = ForeignKey.CASCADE
] )]
) )
@Parcelize @Parcelize
data class CacheEntity( data class CacheEntity(
val id: Long, val id: Long,
val type: String, val type: String,
val lastUpdated: Long, val lastUpdated: Long,
val contents: String val contents: String
) : Parcelable ) : Parcelable
@Dao @Dao
interface CacheDao { interface CacheDao {
@Query("SELECT * FROM frost_cache WHERE id = :id AND type = :type") @Query("SELECT * FROM frost_cache WHERE id = :id AND type = :type")
fun _select(id: Long, type: String): CacheEntity? fun _select(id: Long, type: String): CacheEntity?
@Insert(onConflict = OnConflictStrategy.REPLACE) @Insert(onConflict = OnConflictStrategy.REPLACE) fun _insertCache(cache: CacheEntity)
fun _insertCache(cache: CacheEntity)
@Query("DELETE FROM frost_cache WHERE id = :id AND type = :type") @Query("DELETE FROM frost_cache WHERE id = :id AND type = :type")
fun _delete(id: Long, type: String) fun _delete(id: Long, type: String)
} }
suspend fun CacheDao.select(id: Long, type: String) = dao { suspend fun CacheDao.select(id: Long, type: String) = dao { _select(id, type) }
_select(id, type)
}
suspend fun CacheDao.delete(id: Long, type: String) = dao { suspend fun CacheDao.delete(id: Long, type: String) = dao { _delete(id, type) }
_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 { suspend fun CacheDao.save(id: Long, type: String, contents: String): Boolean = dao {
try { try {
_insertCache(CacheEntity(id, type, System.currentTimeMillis(), contents)) _insertCache(CacheEntity(id, type, System.currentTimeMillis(), contents))
true true
} catch (e: Exception) { } catch (e: Exception) {
L.e(e) { "Cache save failed for $type" } L.e(e) { "Cache save failed for $type" }
false false
} }
} }

View File

@ -28,59 +28,57 @@ import androidx.sqlite.db.SupportSQLiteDatabase
import com.pitchedapps.frost.prefs.Prefs import com.pitchedapps.frost.prefs.Prefs
import kotlinx.android.parcel.Parcelize import kotlinx.android.parcel.Parcelize
/** /** Created by Allan Wang on 2017-05-30. */
* Created by Allan Wang on 2017-05-30.
*/
@Entity(tableName = "cookies") @Entity(tableName = "cookies")
@Parcelize @Parcelize
data class CookieEntity( data class CookieEntity(
@androidx.room.PrimaryKey @androidx.room.PrimaryKey @ColumnInfo(name = "cookie_id") val id: Long,
@ColumnInfo(name = "cookie_id") val name: String?,
val id: Long, val cookie: String?,
val name: String?, val cookieMessenger: String? = null // Version 2
val cookie: String?,
val cookieMessenger: String? = null // Version 2
) : Parcelable { ) : Parcelable {
override fun toString(): String = "CookieEntity(${hashCode()})" override fun toString(): String = "CookieEntity(${hashCode()})"
fun toSensitiveString(): String = fun toSensitiveString(): String =
"CookieEntity(id=$id, name=$name, cookie=$cookie cookieMessenger=$cookieMessenger)" "CookieEntity(id=$id, name=$name, cookie=$cookie cookieMessenger=$cookieMessenger)"
} }
@Dao @Dao
interface CookieDao { interface CookieDao {
@Query("SELECT * FROM cookies") @Query("SELECT * FROM cookies") fun _selectAll(): List<CookieEntity>
fun _selectAll(): List<CookieEntity>
@Query("SELECT * FROM cookies WHERE cookie_id = :id") @Query("SELECT * FROM cookies WHERE cookie_id = :id") fun _selectById(id: Long): CookieEntity?
fun _selectById(id: Long): CookieEntity?
@Insert(onConflict = OnConflictStrategy.REPLACE) @Insert(onConflict = OnConflictStrategy.REPLACE) fun _save(cookie: CookieEntity)
fun _save(cookie: CookieEntity)
@Insert(onConflict = OnConflictStrategy.REPLACE) @Insert(onConflict = OnConflictStrategy.REPLACE) fun _save(cookies: List<CookieEntity>)
fun _save(cookies: List<CookieEntity>)
@Query("DELETE FROM cookies WHERE cookie_id = :id") @Query("DELETE FROM cookies WHERE cookie_id = :id") fun _deleteById(id: Long)
fun _deleteById(id: Long)
@Query("UPDATE cookies SET cookieMessenger = :cookie WHERE cookie_id = :id") @Query("UPDATE cookies SET cookieMessenger = :cookie WHERE cookie_id = :id")
fun _updateMessengerCookie(id: Long, cookie: String?) fun _updateMessengerCookie(id: Long, cookie: String?)
} }
suspend fun CookieDao.selectAll() = dao { _selectAll() } 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<CookieEntity>) = 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) { suspend fun CookieDao.selectById(id: Long) = dao { _selectById(id) }
override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL("ALTER TABLE cookies ADD COLUMN cookieMessenger TEXT") suspend fun CookieDao.save(cookie: CookieEntity) = dao { _save(cookie) }
}
suspend fun CookieDao.save(cookies: List<CookieEntity>) = 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")
}
}

View File

@ -20,9 +20,8 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
/** /**
* Wraps dao calls to work with coroutines * Wraps dao calls to work with coroutines Non transactional queries were supposed to be fixed in
* Non transactional queries were supposed to be fixed in https://issuetracker.google.com/issues/69474692, * https://issuetracker.google.com/issues/69474692, but it still requires dispatch from a non ui
* but it still requires dispatch from a non ui thread. * thread. This avoids that constraint
* This avoids that constraint
*/ */
suspend inline fun <T> dao(crossinline block: () -> T) = withContext(Dispatchers.IO) { block() } suspend inline fun <T> dao(crossinline block: () -> T) = withContext(Dispatchers.IO) { block() }

View File

@ -29,98 +29,100 @@ import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton import javax.inject.Singleton
interface FrostPrivateDao { interface FrostPrivateDao {
fun cookieDao(): CookieDao fun cookieDao(): CookieDao
fun notifDao(): NotificationDao fun notifDao(): NotificationDao
fun cacheDao(): CacheDao fun cacheDao(): CacheDao
} }
@Database( @Database(
entities = [CookieEntity::class, NotificationEntity::class, CacheEntity::class], entities = [CookieEntity::class, NotificationEntity::class, CacheEntity::class],
version = 2, version = 2,
exportSchema = true exportSchema = true
) )
abstract class FrostPrivateDatabase : RoomDatabase(), FrostPrivateDao { abstract class FrostPrivateDatabase : RoomDatabase(), FrostPrivateDao {
companion object { companion object {
const val DATABASE_NAME = "frost-priv-db" const val DATABASE_NAME = "frost-priv-db"
} }
} }
interface FrostPublicDao { interface FrostPublicDao {
fun genericDao(): GenericDao fun genericDao(): GenericDao
} }
@Database(entities = [GenericEntity::class], version = 1, exportSchema = true) @Database(entities = [GenericEntity::class], version = 1, exportSchema = true)
abstract class FrostPublicDatabase : RoomDatabase(), FrostPublicDao { abstract class FrostPublicDatabase : RoomDatabase(), FrostPublicDao {
companion object { companion object {
const val DATABASE_NAME = "frost-db" const val DATABASE_NAME = "frost-db"
} }
} }
interface FrostDao : FrostPrivateDao, FrostPublicDao { interface FrostDao : FrostPrivateDao, FrostPublicDao {
fun close() fun close()
} }
/** /** Composition of all database interfaces */
* Composition of all database interfaces
*/
class FrostDatabase( class FrostDatabase(
private val privateDb: FrostPrivateDatabase, private val privateDb: FrostPrivateDatabase,
private val publicDb: FrostPublicDatabase private val publicDb: FrostPublicDatabase
) : ) : FrostDao, FrostPrivateDao by privateDb, FrostPublicDao by publicDb {
FrostDao,
FrostPrivateDao by privateDb,
FrostPublicDao by publicDb {
override fun close() { override fun close() {
privateDb.close() privateDb.close()
publicDb.close() publicDb.close()
} }
companion object { companion object {
private fun <T : RoomDatabase> RoomDatabase.Builder<T>.frostBuild() = private fun <T : RoomDatabase> RoomDatabase.Builder<T>.frostBuild() =
if (BuildConfig.DEBUG) { if (BuildConfig.DEBUG) {
fallbackToDestructiveMigration().build() fallbackToDestructiveMigration().build()
} else { } else {
build() build()
} }
fun create(context: Context): FrostDatabase { fun create(context: Context): FrostDatabase {
val privateDb = Room.databaseBuilder( val privateDb =
context, FrostPrivateDatabase::class.java, Room.databaseBuilder(
FrostPrivateDatabase.DATABASE_NAME context,
).addMigrations(COOKIES_MIGRATION_1_2).frostBuild() FrostPrivateDatabase::class.java,
val publicDb = Room.databaseBuilder( FrostPrivateDatabase.DATABASE_NAME
context, FrostPublicDatabase::class.java, )
FrostPublicDatabase.DATABASE_NAME .addMigrations(COOKIES_MIGRATION_1_2)
).frostBuild() .frostBuild()
return FrostDatabase(privateDb, publicDb) val publicDb =
} Room.databaseBuilder(
context,
FrostPublicDatabase::class.java,
FrostPublicDatabase.DATABASE_NAME
)
.frostBuild()
return FrostDatabase(privateDb, publicDb)
} }
}
} }
@Module @Module
@InstallIn(SingletonComponent::class) @InstallIn(SingletonComponent::class)
object DatabaseModule { object DatabaseModule {
@Provides @Provides
@Singleton @Singleton
fun frostDatabase(@ApplicationContext context: Context): FrostDatabase = fun frostDatabase(@ApplicationContext context: Context): FrostDatabase =
FrostDatabase.create(context) FrostDatabase.create(context)
@Provides @Provides
@Singleton @Singleton
fun cookieDao(frostDatabase: FrostDatabase): CookieDao = frostDatabase.cookieDao() fun cookieDao(frostDatabase: FrostDatabase): CookieDao = frostDatabase.cookieDao()
@Provides @Provides
@Singleton @Singleton
fun cacheDao(frostDatabase: FrostDatabase): CacheDao = frostDatabase.cacheDao() fun cacheDao(frostDatabase: FrostDatabase): CacheDao = frostDatabase.cacheDao()
@Provides @Provides
@Singleton @Singleton
fun notifDao(frostDatabase: FrostDatabase): NotificationDao = frostDatabase.notifDao() fun notifDao(frostDatabase: FrostDatabase): NotificationDao = frostDatabase.notifDao()
@Provides @Provides
@Singleton @Singleton
fun genericDao(frostDatabase: FrostDatabase): GenericDao = frostDatabase.genericDao() fun genericDao(frostDatabase: FrostDatabase): GenericDao = frostDatabase.genericDao()
} }

View File

@ -25,49 +25,35 @@ import androidx.room.Query
import com.pitchedapps.frost.facebook.FbItem import com.pitchedapps.frost.facebook.FbItem
import com.pitchedapps.frost.facebook.defaultTabs 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") @Entity(tableName = "frost_generic")
data class GenericEntity( data class GenericEntity(@PrimaryKey val type: String, val contents: String)
@PrimaryKey
val type: String,
val contents: String
)
@Dao @Dao
interface GenericDao { interface GenericDao {
@Query("SELECT contents FROM frost_generic WHERE type = :type") @Query("SELECT contents FROM frost_generic WHERE type = :type") fun _select(type: String): String?
fun _select(type: String): String?
@Insert(onConflict = OnConflictStrategy.REPLACE) @Insert(onConflict = OnConflictStrategy.REPLACE) fun _save(entity: GenericEntity)
fun _save(entity: GenericEntity)
@Query("DELETE FROM frost_generic WHERE type = :type") @Query("DELETE FROM frost_generic WHERE type = :type") fun _delete(type: String)
fun _delete(type: String)
companion object { companion object {
const val TYPE_TABS = "generic_tabs" const val TYPE_TABS = "generic_tabs"
} }
} }
const val TAB_COUNT = 4 const val TAB_COUNT = 4
suspend fun GenericDao.saveTabs(tabs: List<FbItem>) = dao { suspend fun GenericDao.saveTabs(tabs: List<FbItem>) = dao {
val content = tabs.joinToString(",") { it.name } val content = tabs.joinToString(",") { it.name }
_save(GenericEntity(GenericDao.TYPE_TABS, content)) _save(GenericEntity(GenericDao.TYPE_TABS, content))
} }
suspend fun GenericDao.getTabs(): List<FbItem> = dao { suspend fun GenericDao.getTabs(): List<FbItem> = dao {
val allTabs = FbItem.values.map { it.name to it }.toMap() val allTabs = FbItem.values.map { it.name to it }.toMap()
_select(GenericDao.TYPE_TABS) _select(GenericDao.TYPE_TABS)?.split(",")?.mapNotNull { allTabs[it] }?.takeIf { it.isNotEmpty() }
?.split(",") ?: defaultTabs()
?.mapNotNull { allTabs[it] }
?.takeIf { it.isNotEmpty() }
?: defaultTabs()
} }

View File

@ -30,127 +30,120 @@ import com.pitchedapps.frost.services.NotificationContent
import com.pitchedapps.frost.utils.L import com.pitchedapps.frost.utils.L
@Entity( @Entity(
tableName = "notifications", tableName = "notifications",
primaryKeys = ["notif_id", "userId"], primaryKeys = ["notif_id", "userId"],
foreignKeys = [ foreignKeys =
ForeignKey( [
entity = CookieEntity::class, ForeignKey(
parentColumns = ["cookie_id"], entity = CookieEntity::class,
childColumns = ["userId"], parentColumns = ["cookie_id"],
onDelete = ForeignKey.CASCADE childColumns = ["userId"],
) onDelete = ForeignKey.CASCADE
], )],
indices = [Index("notif_id"), Index("userId")] indices = [Index("notif_id"), Index("userId")]
) )
data class NotificationEntity( data class NotificationEntity(
@ColumnInfo(name = "notif_id") @ColumnInfo(name = "notif_id") val id: Long,
val id: Long, val userId: Long,
val userId: Long, val href: String,
val href: String, val title: String?,
val title: String?, val text: String,
val text: String, val timestamp: Long,
val timestamp: Long, val profileUrl: String?,
val profileUrl: String?, // Type essentially refers to channel
// Type essentially refers to channel val type: String,
val type: String, val unread: Boolean
val unread: Boolean
) { ) {
constructor( constructor(
type: String, type: String,
content: NotificationContent content: NotificationContent
) : this( ) : this(
content.id, content.id,
content.data.id, content.data.id,
content.href, content.href,
content.title, content.title,
content.text, content.text,
content.timestamp, content.timestamp,
content.profileUrl, content.profileUrl,
type, type,
content.unread content.unread
) )
} }
data class NotificationContentEntity( data class NotificationContentEntity(
@Embedded @Embedded val cookie: CookieEntity,
val cookie: CookieEntity, @Embedded val notif: NotificationEntity
@Embedded
val notif: NotificationEntity
) { ) {
fun toNotifContent() = NotificationContent( fun toNotifContent() =
data = cookie, NotificationContent(
id = notif.id, data = cookie,
href = notif.href, id = notif.id,
title = notif.title, href = notif.href,
text = notif.text, title = notif.title,
timestamp = notif.timestamp, text = notif.text,
profileUrl = notif.profileUrl, timestamp = notif.timestamp,
unread = notif.unread profileUrl = notif.profileUrl,
unread = notif.unread
) )
} }
@Dao @Dao
interface NotificationDao { interface NotificationDao {
/** /** Note that notifications are guaranteed to be ordered by descending timestamp */
* Note that notifications are guaranteed to be ordered by descending timestamp @Transaction
*/ @Query(
@Transaction "SELECT * FROM cookies INNER JOIN notifications ON cookie_id = userId WHERE userId = :userId AND type = :type ORDER BY timestamp DESC"
@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<NotificationContentEntity> fun _selectNotifications(userId: Long, type: String): List<NotificationContentEntity>
@Query("SELECT timestamp FROM notifications WHERE userId = :userId AND type = :type ORDER BY timestamp DESC LIMIT 1") @Query(
fun _selectEpoch(userId: Long, type: String): Long? "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) @Insert(onConflict = OnConflictStrategy.REPLACE)
fun _insertNotifications(notifs: List<NotificationEntity>) fun _insertNotifications(notifs: List<NotificationEntity>)
@Query("DELETE FROM notifications WHERE userId = :userId AND type = :type") @Query("DELETE FROM notifications WHERE userId = :userId AND type = :type")
fun _deleteNotifications(userId: Long, type: String) fun _deleteNotifications(userId: Long, type: String)
@Query("DELETE FROM notifications") @Query("DELETE FROM notifications") fun _deleteAll()
fun _deleteAll()
/** /** It is assumed that the notification batch comes from the same user */
* It is assumed that the notification batch comes from the same user @Transaction
*/ fun _saveNotifications(type: String, notifs: List<NotificationContent>) {
@Transaction val userId = notifs.firstOrNull()?.data?.id ?: return
fun _saveNotifications(type: String, notifs: List<NotificationContent>) { val entities = notifs.map { NotificationEntity(type, it) }
val userId = notifs.firstOrNull()?.data?.id ?: return _deleteNotifications(userId, type)
val entities = notifs.map { NotificationEntity(type, it) } _insertNotifications(entities)
_deleteNotifications(userId, type) }
_insertNotifications(entities)
}
} }
suspend fun NotificationDao.deleteAll() = dao { _deleteAll() } suspend fun NotificationDao.deleteAll() = dao { _deleteAll() }
fun NotificationDao.selectNotificationsSync(userId: Long, type: String): List<NotificationContent> = fun NotificationDao.selectNotificationsSync(userId: Long, type: String): List<NotificationContent> =
_selectNotifications(userId, type).map { it.toNotifContent() } _selectNotifications(userId, type).map { it.toNotifContent() }
suspend fun NotificationDao.selectNotifications( suspend fun NotificationDao.selectNotifications(
userId: Long, userId: Long,
type: String type: String
): List<NotificationContent> = dao { ): List<NotificationContent> = dao { selectNotificationsSync(userId, type) }
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( suspend fun NotificationDao.saveNotifications(
type: String, type: String,
notifs: List<NotificationContent> notifs: List<NotificationContent>
): Boolean = dao { ): Boolean = dao {
try { try {
_saveNotifications(type, notifs) _saveNotifications(type, notifs)
true true
} catch (e: Exception) { } catch (e: Exception) {
L.e(e) { "Notif save failed for $type" } L.e(e) { "Notif save failed for $type" }
false false
} }
} }
suspend fun NotificationDao.latestEpoch(userId: Long, type: String): Long = dao { suspend fun NotificationDao.latestEpoch(userId: Long, type: String): Long = dao {
_selectEpoch(userId, type) ?: -1L _selectEpoch(userId, type) ?: -1L
} }

View File

@ -26,6 +26,12 @@ import com.pitchedapps.frost.utils.createFreshDir
import com.pitchedapps.frost.utils.createFreshFile import com.pitchedapps.frost.utils.createFreshFile
import com.pitchedapps.frost.utils.frostJsoup import com.pitchedapps.frost.utils.frostJsoup
import com.pitchedapps.frost.utils.unescapeHtml 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.Dispatchers
import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@ -37,12 +43,6 @@ import org.jsoup.Jsoup
import org.jsoup.nodes.Document import org.jsoup.nodes.Document
import org.jsoup.nodes.Element import org.jsoup.nodes.Element
import org.jsoup.nodes.Entities 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. * Created by Allan Wang on 04/01/18.
@ -52,281 +52,271 @@ import java.util.zip.ZipOutputStream
* Inspired by <a href="https://github.com/JonasCz/save-for-offline">Save for Offline</a> * Inspired by <a href="https://github.com/JonasCz/save-for-offline">Save for Offline</a>
*/ */
class OfflineWebsite( class OfflineWebsite(
private val url: String, private val url: String,
private val cookie: String = "", private val cookie: String = "",
baseUrl: String? = null, baseUrl: String? = null,
private val html: String? = null, private val html: String? = null,
/** /** Directory that holds all the files */
* Directory that holds all the files val baseDir: File,
*/ private val userAgent: String = USER_AGENT
val baseDir: File,
private val userAgent: String = USER_AGENT
) { ) {
/** /** Supplied url without the queries */
* Supplied url without the queries private val baseUrl: String =
*/ baseUrl
private val baseUrl: String = baseUrl ?: run { ?: run {
val url: HttpUrl = url.toHttpUrlOrNull() ?: throw IllegalArgumentException("Malformed url") val url: HttpUrl = url.toHttpUrlOrNull() ?: throw IllegalArgumentException("Malformed url")
return@run "${url.scheme}://${url.host}" return@run "${url.scheme}://${url.host}"
}
private val mainFile = File(baseDir, "index.html")
private val assetDir = File(baseDir, "assets")
private val urlMapper = ConcurrentHashMap<String, String>()
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<String>()
private val cssQueue = mutableSetOf<String>()
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>${html.unescapeHtml()}</html>")
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") fun zip(name: String): Boolean {
private val assetDir = File(baseDir, "assets") 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<String, String>() ZipOutputStream(FileOutputStream(zip)).use { out ->
private val atomicInt = AtomicInteger() 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) assetDir.delete()
}
init { return true
if (!this.baseUrl.startsWith("http")) } catch (e: Exception) {
throw IllegalArgumentException("Base Url must start with http") L.e { "Zip failed: ${e.message}" }
return false
} }
}
private val fileQueue = mutableSetOf<String>() suspend fun loadAndZip(name: String, progress: (Int) -> Unit = {}): Boolean =
withContext(Dispatchers.IO) {
private val cssQueue = mutableSetOf<String>() coroutineScope {
val success = load { progress((it * 0.85f).toInt()) }
private fun request(url: String) = Request.Builder() if (!success) return@coroutineScope false
.header("Cookie", cookie) val result = zip(name)
.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>${html.unescapeHtml()}</html>")
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) progress(100)
return@withContext true return@coroutineScope result
}
} }
fun zip(name: String): Boolean { private fun downloadFile(url: String): Boolean {
try { return try {
val zip = File(baseDir, "$name.zip") val file = File(assetDir, fileName(url))
if (!zip.createFreshFile()) { file.createNewFile()
L.e { "Failed to create zip at ${zip.absolutePath}" } val stream =
return false request(url).execute().body?.byteStream()
} ?: throw IllegalArgumentException("Response body not found for $url")
file.copyFromInputStream(stream)
ZipOutputStream(FileOutputStream(zip)).use { out -> true
} catch (e: Exception) {
fun File.zip(name: String = this.name) { L.e(e) { "Download file failed" }
if (!isFile) return false
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
}
} }
}
suspend fun loadAndZip(name: String, progress: (Int) -> Unit = {}): Boolean = private fun downloadCss(url: String): Set<String> {
withContext(Dispatchers.IO) { return try {
coroutineScope { val file = File(assetDir, fileName(url))
val success = load { progress((it * 0.85f).toInt()) } file.createNewFile()
if (!success) return@coroutineScope false
val result = zip(name)
progress(100)
return@coroutineScope result
}
}
private fun downloadFile(url: String): Boolean { var content =
return try { request(url).execute().body?.string()
val file = File(assetDir, fileName(url)) ?: throw IllegalArgumentException("Response body not found for $url")
file.createNewFile() val links = FB_CSS_URL_MATCHER.findAll(content).mapNotNull { it[1] }
val stream = request(url).execute().body?.byteStream() val absLinks =
?: throw IllegalArgumentException("Response body not found for $url") links
file.copyFromInputStream(stream) .mapNotNull {
true val newUrl =
} catch (e: Exception) { when {
L.e(e) { "Download file failed" } it.startsWith("http") -> it
false 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<String> { private fun Element.collect(query: String, key: String, collector: MutableSet<String>) {
return try { val data = select(query)
val file = File(assetDir, fileName(url)) L.v { "Found ${data.size} elements with $query" }
file.createNewFile() data.forEach {
val absLink = it.attr("abs:$key")
var content = request(url).execute().body?.string() if (!absLink.isValid) return@forEach
?: throw IllegalArgumentException("Response body not found for $url") collector.add(absLink)
val links = FB_CSS_URL_MATCHER.findAll(content).mapNotNull { it[1] } it.attr(key, "assets/${fileName(absLink)}")
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<String>) { private inline val String.isValid
val data = select(query) get() = startsWith("http")
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 /** Fetch the previously discovered filename or create a new one This is thread-safe */
get() = startsWith("http") 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 * This is primarily for zipping up and sending via emails As .js files typically aren't
* or create a new one * allowed, we'll simply make everything txt files
* This is thread-safe
*/ */
private fun fileName(url: String): String { if (newUrl.endsWith(".js")) newUrl = "$newUrl.txt"
val mapped = urlMapper[url]
if (mapped != null) return mapped
val candidate = url.substringBefore("?").trim('/') urlMapper[url] = newUrl
.substringAfterLast("/").shorten() 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<String>.clean(): List<String> =
filter(String::isNotBlank).filter { it.startsWith("http") }
/** private fun reset() {
* This is primarily for zipping up and sending via emails urlMapper.clear()
* As .js files typically aren't allowed, we'll simply make everything txt files atomicInt.set(0)
*/ fileQueue.clear()
if (newUrl.endsWith(".js")) cssQueue.clear()
newUrl = "$newUrl.txt" }
urlMapper[url] = newUrl
return newUrl
}
private fun String.shorten() =
if (length <= 10) this else substring(length - 10)
private fun Set<String>.clean(): List<String> =
filter(String::isNotBlank).filter { it.startsWith("http") }
private fun reset() {
urlMapper.clear()
atomicInt.set(0)
fileQueue.clear()
cssQueue.clear()
}
} }

View File

@ -20,16 +20,14 @@ import androidx.annotation.StringRes
import com.pitchedapps.frost.R import com.pitchedapps.frost.R
import com.pitchedapps.frost.facebook.FbItem 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) { enum class FeedSort(@StringRes val textRes: Int, val item: FbItem) {
DEFAULT(R.string.kau_default, FbItem.FEED), DEFAULT(R.string.kau_default, FbItem.FEED),
MOST_RECENT(R.string.most_recent, FbItem.FEED_MOST_RECENT), MOST_RECENT(R.string.most_recent, FbItem.FEED_MOST_RECENT),
TOP(R.string.top_stories, FbItem.FEED_TOP_STORIES); TOP(R.string.top_stories, FbItem.FEED_TOP_STORIES);
companion object { companion object {
val values = values() // save one instance val values = values() // save one instance
operator fun invoke(index: Int) = values[index] operator fun invoke(index: Int) = values[index]
} }
} }

View File

@ -19,29 +19,17 @@ package com.pitchedapps.frost.enums
import com.pitchedapps.frost.R import com.pitchedapps.frost.R
import com.pitchedapps.frost.injectors.ThemeProvider 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( enum class MainActivityLayout(
val titleRes: Int, val titleRes: Int,
val backgroundColor: (ThemeProvider) -> Int, val backgroundColor: (ThemeProvider) -> Int,
val iconColor: (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( companion object {
R.string.top_bar, val values = values() // save one instance
{ it.headerColor }, operator fun invoke(index: Int) = values.getOrElse(index) { TOP_BAR }
{ 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 }
}
} }

View File

@ -30,51 +30,45 @@ import com.pitchedapps.frost.views.FrostWebView
/** /**
* Created by Allan Wang on 2017-09-16. * Created by Allan Wang on 2017-09-16.
* *
* Options for [WebOverlayActivityBase] to give more info as to what kind of * Options for [WebOverlayActivityBase] to give more info as to what kind of overlay is present.
* overlay is present.
* *
* For now, this is able to add new menu options upon first load * For now, this is able to add new menu options upon first load
*/ */
enum class OverlayContext(private val menuItem: FrostMenuItem?) : EnumBundle<OverlayContext> { enum class OverlayContext(private val menuItem: FrostMenuItem?) : EnumBundle<OverlayContext> {
NOTIFICATION(FrostMenuItem(R.id.action_notification, FbItem.NOTIFICATIONS)),
MESSAGE(FrostMenuItem(R.id.action_messages, FbItem.MESSAGES));
NOTIFICATION(FrostMenuItem(R.id.action_notification, FbItem.NOTIFICATIONS)), /** Inject the [menuItem] in the order that they are given at the front of the menu */
MESSAGE(FrostMenuItem(R.id.action_messages, FbItem.MESSAGES)); fun onMenuCreate(context: Context, menu: Menu) {
menuItem?.addToMenu(context, menu, 0)
}
override val bundleContract: EnumBundleCompanion<OverlayContext>
get() = Companion
companion object : EnumCompanion<OverlayContext>("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) { fun onOptionsItemSelected(web: FrostWebView, id: Int): Boolean {
menuItem?.addToMenu(context, menu, 0) val item = values.firstOrNull { id == it.menuItem?.id }?.menuItem ?: return false
} web.loadUrl(item.fbItem.url, true)
return true
override val bundleContract: EnumBundleCompanion<OverlayContext>
get() = Companion
companion object : EnumCompanion<OverlayContext>("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
}
} }
}
} }
/** /** Frame for an injectable menu item */
* Frame for an injectable menu item
*/
class FrostMenuItem( class FrostMenuItem(
val id: Int, val id: Int,
val fbItem: FbItem, val fbItem: FbItem,
val showAsAction: Int = MenuItem.SHOW_AS_ACTION_IF_ROOM val showAsAction: Int = MenuItem.SHOW_AS_ACTION_IF_ROOM
) { ) {
fun addToMenu(context: Context, menu: Menu, index: Int) { fun addToMenu(context: Context, menu: Menu, index: Int) {
val item = menu.add(Menu.NONE, id, index, fbItem.titleId) val item = menu.add(Menu.NONE, id, index, fbItem.titleId)
item.icon = fbItem.icon.toDrawable(context, 18) item.icon = fbItem.icon.toDrawable(context, 18)
item.setShowAsAction(showAsAction) item.setShowAsAction(showAsAction)
} }
} }

View File

@ -23,95 +23,85 @@ import com.pitchedapps.frost.R
import com.pitchedapps.frost.prefs.sections.ThemePrefs import com.pitchedapps.frost.prefs.sections.ThemePrefs
import java.util.Locale 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 FACEBOOK_BLUE = 0xff3b5998.toInt()
const val BLUE_LIGHT = 0xff5d86dd.toInt() const val BLUE_LIGHT = 0xff5d86dd.toInt()
enum class Theme( enum class Theme(
@StringRes val textRes: Int, @StringRes val textRes: Int,
file: String?, file: String?,
val textColorGetter: (ThemePrefs) -> Int, val textColorGetter: (ThemePrefs) -> Int,
val accentColorGetter: (ThemePrefs) -> Int, val accentColorGetter: (ThemePrefs) -> Int,
val backgroundColorGetter: (ThemePrefs) -> Int, val backgroundColorGetter: (ThemePrefs) -> Int,
val headerColorGetter: (ThemePrefs) -> Int, val headerColorGetter: (ThemePrefs) -> Int,
val iconColorGetter: (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( @VisibleForTesting internal val file = file?.let { "$it.css" }
R.string.kau_default,
"default",
{ 0xde000000.toInt() },
{ FACEBOOK_BLUE },
{ 0xfffafafa.toInt() },
{ FACEBOOK_BLUE },
{ Color.WHITE }
),
LIGHT( companion object {
R.string.kau_light, val values = values() // save one instance
"material_light", operator fun invoke(index: Int) = values[index]
{ 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]
}
} }
enum class ThemeCategory { enum class ThemeCategory {
FACEBOOK, MESSENGER FACEBOOK,
; MESSENGER;
@VisibleForTesting @VisibleForTesting internal val folder = name.toLowerCase(Locale.CANADA)
internal val folder = name.toLowerCase(Locale.CANADA)
} }

View File

@ -16,10 +16,7 @@
*/ */
package com.pitchedapps.frost.facebook 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 FACEBOOK_COM = "facebook.com"
const val MESSENGER_COM = "messenger.com" const val MESSENGER_COM = "messenger.com"
const val FBCDN_NET = "fbcdn.net" 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 FB_URL_BASE = "https://$FACEBOOK_BASE_COM/"
const val FACEBOOK_MBASIC_COM = "mbasic.$FACEBOOK_COM" const val FACEBOOK_MBASIC_COM = "mbasic.$FACEBOOK_COM"
const val FB_URL_MBASIC_BASE = "https://$FACEBOOK_MBASIC_COM/" const val FB_URL_MBASIC_BASE = "https://$FACEBOOK_MBASIC_COM/"
fun profilePictureUrl(id: Long) = "https://graph.facebook.com/$id/picture?type=large" fun profilePictureUrl(id: Long) = "https://graph.facebook.com/$id/picture?type=large"
const val FB_LOGIN_URL = "${FB_URL_BASE}login" const val FB_LOGIN_URL = "${FB_URL_BASE}login"
const val FB_HOME_URL = "${FB_URL_BASE}home.php" const val FB_HOME_URL = "${FB_URL_BASE}home.php"
const val MESSENGER_THREAD_PREFIX = "$HTTPS_MESSENGER_COM/t/" 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 // Default user agent
const val USER_AGENT_MOBILE_CONST = 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 // Desktop agent, for pages like messages
const val USER_AGENT_DESKTOP_CONST = 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 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 const val WEB_LOAD_DELAY = 50L
/** /**
* Additional delay for transition when called from commit. * Additional delay for transition when called from commit. Note that transitions are also called
* Note that transitions are also called from onFinish, so this value * from onFinish, so this value will never make a load slower than it is
* will never make a load slower than it is
*/ */
const val WEB_COMMIT_LOAD_DELAY = 200L const val WEB_COMMIT_LOAD_DELAY = 200L

View File

@ -28,149 +28,129 @@ import com.pitchedapps.frost.prefs.Prefs
import com.pitchedapps.frost.utils.L import com.pitchedapps.frost.utils.L
import com.pitchedapps.frost.utils.cookies import com.pitchedapps.frost.utils.cookies
import com.pitchedapps.frost.utils.launchLogin 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.Dispatchers
import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.async import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import javax.inject.Inject
import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine
/** /**
* Created by Allan Wang on 2017-05-30. * Created by Allan Wang on 2017-05-30.
* *
* The following component manages all cookie transfers. * The following component manages all cookie transfers.
*/ */
class FbCookie @Inject internal constructor( class FbCookie
private val prefs: Prefs, @Inject
private val cookieDao: CookieDao internal constructor(private val prefs: Prefs, private val cookieDao: CookieDao) {
) {
companion object { companion object {
/** /** Domain information. Dot prefix still matters for Android browsers. */
* 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"
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) }
} }
/** private suspend fun CookieManager.removeAllCookies(): Boolean = suspendCoroutine { cont ->
* Retrieves the facebook cookie if it exists removeAllCookies { cont.resume(it) }
* Note that this is a synchronized call }
*/
val webCookie: String?
get() = CookieManager.getInstance().getCookie(HTTPS_FACEBOOK_COM)
val messengerCookie: String? suspend fun save(id: Long) {
get() = CookieManager.getInstance().getCookie(HTTPS_MESSENGER_COM) 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( suspend fun reset() {
domain: String, prefs.userId = -1L
cookie: String? withContext(Dispatchers.Main + NonCancellable) {
): Boolean { with(CookieManager.getInstance()) {
cookie ?: return true removeAllCookies()
return withContext(NonCancellable) { flush()
// 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 = suspend fun switchUser(id: Long) {
suspendCoroutine { cont -> val cookie = cookieDao.selectById(id) ?: return L.e { "No cookie for id" }
setCookie(domain, cookie.trim()) { switchUser(cookie)
cont.resume(it) }
}
}
private suspend fun CookieManager.removeAllCookies(): Boolean = suspendCoroutine { cont -> suspend fun switchUser(cookie: CookieEntity?) {
removeAllCookies { if (cookie?.cookie == null) {
cont.resume(it) L.d { "Switching User; null cookie" }
} return
} }
withContext(Dispatchers.Main + NonCancellable) {
suspend fun save(id: Long) { L.d { "Switching User" }
L.d { "New cookie found" } prefs.userId = cookie.id
prefs.userId = id CookieManager.getInstance().apply {
CookieManager.getInstance().flush() removeAllCookies()
val cookie = CookieEntity(prefs.userId, null, webCookie) suspendSetWebCookie(FB_COOKIE_DOMAIN, cookie.cookie)
cookieDao.save(cookie) suspendSetWebCookie(MESSENGER_COOKIE_DOMAIN, cookie.cookieMessenger)
flush()
}
} }
}
suspend fun reset() { /** Helper function to remove the current cookies and launch the proper login page */
prefs.userId = -1L suspend fun logout(context: Context, deleteCookie: Boolean = true) {
withContext(Dispatchers.Main + NonCancellable) { val cookies = arrayListOf<CookieEntity>()
with(CookieManager.getInstance()) { if (context is Activity) cookies.addAll(context.cookies().filter { it.id != prefs.userId })
removeAllCookies() logout(prefs.userId, deleteCookie)
flush() context.launchLogin(cookies, true)
} }
}
}
suspend fun switchUser(id: Long) { /** Clear the cookies of the given id */
val cookie = cookieDao.selectById(id) ?: return L.e { "No cookie for id" } suspend fun logout(id: Long, deleteCookie: Boolean = true) {
switchUser(cookie) 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) { * Notifications may come from different accounts, and we need to switch the cookies to load them
L.d { "Switching User; null cookie" } * When coming back to the main app, switch back to our original account before continuing
return */
} suspend fun switchBackUser() {
withContext(Dispatchers.Main + NonCancellable) { if (prefs.prevId == -1L) return
L.d { "Switching User" } val prevId = prefs.prevId
prefs.userId = cookie.id prefs.prevId = -1L
CookieManager.getInstance().apply { if (prevId != prefs.userId) {
removeAllCookies() switchUser(prevId)
suspendSetWebCookie(FB_COOKIE_DOMAIN, cookie.cookie) L.d { "Switch back user" }
suspendSetWebCookie(MESSENGER_COOKIE_DOMAIN, cookie.cookieMessenger) L._d { "${prefs.userId} to $prevId" }
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<CookieEntity>()
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" }
}
} }
}
} }

View File

@ -29,79 +29,73 @@ import com.pitchedapps.frost.utils.EnumBundleCompanion
import com.pitchedapps.frost.utils.EnumCompanion import com.pitchedapps.frost.utils.EnumCompanion
enum class FbItem( enum class FbItem(
@StringRes val titleId: Int, @StringRes val titleId: Int,
val icon: IIcon, val icon: IIcon,
relativeUrl: String, relativeUrl: String,
val fragmentCreator: () -> BaseFragment = ::WebFragment, val fragmentCreator: () -> BaseFragment = ::WebFragment,
prefix: String = FB_URL_BASE prefix: String = FB_URL_BASE
) : EnumBundle<FbItem> { ) : EnumBundle<FbItem> {
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"), * Unlike other urls, menus cannot be linked directly as it is a soft reference. Instead, we can
CHAT(R.string.chat, GoogleMaterial.Icon.gmd_chat, "buddylist"), * pick any url with the blue bar and manually click to enter the menu.
EVENTS(R.string.events, GoogleMaterial.Icon.gmd_event_note, "events/upcoming"), * We pick home.php as some back interactions default to home regardless of the base url.
FEED(R.string.feed, CommunityMaterial.Icon3.cmd_newspaper, ""), */
FEED_MOST_RECENT(R.string.most_recent, GoogleMaterial.Icon.gmd_history, "home.php?sk=h_chr"), MENU(R.string.menu, GoogleMaterial.Icon.gmd_menu, "home.php"),
FEED_TOP_STORIES(R.string.top_stories, GoogleMaterial.Icon.gmd_star, "home.php?sk=h_nor"), MESSAGES(R.string.messages, MaterialDesignIconic.Icon.gmi_comments, "messages"),
FRIENDS(R.string.friends, GoogleMaterial.Icon.gmd_person_add, "friends/center/requests"), MESSENGER(
GROUPS(R.string.groups, GoogleMaterial.Icon.gmd_group, "groups"), R.string.messenger,
MARKETPLACE(R.string.marketplace, GoogleMaterial.Icon.gmd_store, "marketplace"), 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 */
* Unlike other urls, menus cannot be linked directly as it is a soft reference. Instead, we can _SEARCH(R.string.kau_search, GoogleMaterial.Icon.gmd_search, "search/top"),
* 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"),
/** /** Non mbasic search cannot be parsed. */
* Note that this url only works if a query (?q=) is provided _SEARCH_PARSE(
*/ R.string.kau_search,
_SEARCH( GoogleMaterial.Icon.gmd_search,
R.string.kau_search, "search/top",
GoogleMaterial.Icon.gmd_search, prefix = FB_URL_MBASIC_BASE
"search/top" ),
), SETTINGS(R.string.settings, GoogleMaterial.Icon.gmd_settings, "settings"),
;
/** val url = "$prefix$relativeUrl"
* 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 isFeed: Boolean
get() =
when (this) {
FEED,
FEED_MOST_RECENT,
FEED_TOP_STORIES -> true
else -> false
}
val isFeed: Boolean override val bundleContract: EnumBundleCompanion<FbItem>
get() = when (this) { get() = Companion
FEED, FEED_MOST_RECENT, FEED_TOP_STORIES -> true
else -> false
}
override val bundleContract: EnumBundleCompanion<FbItem> companion object : EnumCompanion<FbItem>("frost_arg_fb_item", values())
get() = Companion
companion object : EnumCompanion<FbItem>("frost_arg_fb_item", values())
} }
fun defaultTabs(): List<FbItem> = fun defaultTabs(): List<FbItem> =
listOf(FbItem.FEED, FbItem.MESSAGES, FbItem.NOTIFICATIONS, FbItem.MENU) listOf(FbItem.FEED, FbItem.MESSAGES, FbItem.NOTIFICATIONS, FbItem.MENU)

View File

@ -19,21 +19,16 @@ package com.pitchedapps.frost.facebook
/** /**
* Created by Allan Wang on 21/12/17. * Created by Allan Wang on 21/12/17.
* *
* Collection of regex matchers * Collection of regex matchers Input text must be properly unescaped
* Input text must be properly unescaped
* *
* See [StringEscapeUtils] * 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_DTSG_MATCHER: Regex by lazy { Regex("name=\"fb_dtsg\" value=\"(.*?)\"") }
val FB_REV_MATCHER: Regex by lazy { Regex("\"app_version\":\"(.*?)\"") } 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_USER_MATCHER: Regex = Regex("c_user=([0-9]*);")
val FB_EPOCH_MATCHER: Regex = Regex(":([0-9]+)") val FB_EPOCH_MATCHER: Regex = Regex(":([0-9]+)")

View File

@ -28,141 +28,164 @@ import java.nio.charset.StandardCharsets
* Custom url builder so we can easily test it without the Android framework * Custom url builder so we can easily test it without the Android framework
*/ */
inline val String.formattedFbUrl: String inline val String.formattedFbUrl: String
get() = FbUrlFormatter(this).toString() get() = FbUrlFormatter(this).toString()
inline val Uri.formattedFbUri: Uri inline val Uri.formattedFbUri: Uri
get() { get() {
val url = toString() val url = toString()
return if (url.startsWith("http")) Uri.parse(url.formattedFbUrl) else this return if (url.startsWith("http")) Uri.parse(url.formattedFbUrl) else this
} }
class FbUrlFormatter(url: String) { class FbUrlFormatter(url: String) {
private val queries = mutableMapOf<String, String>() private val queries = mutableMapOf<String, String>()
private val cleaned: String private val cleaned: String
/** /**
* Formats all facebook urls * Formats all facebook urls
* *
* The order is very important: * The order is very important:
* 1. Wrapper links (discardables) are stripped away, resulting in the actual link * 1. Wrapper links (discardables) are stripped away, resulting in the actual link
* 2. CSS encoding is converted to normal encoding * 2. CSS encoding is converted to normal encoding
* 3. Url is completely decoded * 3. Url is completely decoded
* 4. Url is split into sections * 4. Url is split into sections
*/ */
init { init {
cleaned = clean(url) 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
} }
cleanedUrl = cleanedUrl.replace("&amp;", "&")
fun clean(url: String): String { if (changed && !cleanedUrl.contains("?")) // ensure we aren't missing '?'
if (url.isBlank()) return "" cleanedUrl = cleanedUrl.replaceFirst("&", "?")
var cleanedUrl = url val qm = cleanedUrl.indexOf("?")
if (cleanedUrl.startsWith("#!")) cleanedUrl = cleanedUrl.substring(2) if (qm > -1) {
val urlRef = cleanedUrl cleanedUrl.substring(qm + 1).split("&").forEach {
discardable.forEach { cleanedUrl = cleanedUrl.replace(it, "", true) } val p = it.split("=")
val changed = cleanedUrl != urlRef queries[p[0]] = p.elementAtOrNull(1) ?: ""
converter.forEach { (k, v) -> cleanedUrl = cleanedUrl.replace(k, v, true) } }
try { cleanedUrl = cleanedUrl.substring(0, qm)
cleanedUrl = URLDecoder.decode(cleanedUrl, StandardCharsets.UTF_8.name())
} catch (e: Exception) {
L.e(e) { "Failed url formatting" }
return url
}
cleanedUrl = cleanedUrl.replace("&amp;", "&")
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
} }
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) append(cleaned)
if (queries.isNotEmpty()) { if (queries.isNotEmpty()) {
append("?") append("?")
queries.forEach { (k, v) -> queries.forEach { (k, v) ->
if (v.isEmpty()) { if (v.isEmpty()) {
append("${k.urlEncode()}&") append("${k.urlEncode()}&")
} else { } else {
append("${k.urlEncode()}=${v.urlEncode()}&") append("${k.urlEncode()}=${v.urlEncode()}&")
}
} }
}
} }
}.removeSuffix("&") }
.removeSuffix("&")
fun toLogList(): List<String> { fun toLogList(): List<String> {
val list = mutableListOf(cleaned) val list = mutableListOf(cleaned)
queries.forEach { (k, v) -> list.add("\n- $k\t=\t$v") } queries.forEach { (k, v) -> list.add("\n- $k\t=\t$v") }
list.add("\n\n${toString()}") list.add("\n\n${toString()}")
return list 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 * Items here are explicitly removed from the url Taken from FaceSlim
* Taken from FaceSlim * https://github.com/indywidualny/FaceSlim/blob/master/app/src/main/java/org/indywidualni/fblite/util/Miscellany.java
* 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
* Note: Typically, in this case, the redirect url should have all the necessary queries * unsure how Facebook reacts in all cases, so the ones after the redirect are appended on
* I am unsure how Facebook reacts in all cases, so the ones after the redirect are appended on afterwards * afterwards That shouldn't break anything
* That shouldn't break anything */
*/ val discardable =
val discardable = arrayOf( arrayOf(
"http://lm.facebook.com/l.php?u=", "http://lm.facebook.com/l.php?u=",
"https://lm.facebook.com/l.php?u=", "https://lm.facebook.com/l.php?u=",
"http://m.facebook.com/l.php?u=", "http://m.facebook.com/l.php?u=",
"https://m.facebook.com/l.php?u=", "https://m.facebook.com/l.php?u=",
"http://touch.facebook.com/l.php?u=", "http://touch.facebook.com/l.php?u=",
"https://touch.facebook.com/l.php?u=", "https://touch.facebook.com/l.php?u=",
VIDEO_REDIRECT VIDEO_REDIRECT
) )
/** /**
* Queries that are not necessary for independent links * Queries that are not necessary for independent links
* *
* acontext is not required for "friends interested in" notifications * acontext is not required for "friends interested in" notifications
*/ */
val discardableQueries = arrayOf( val discardableQueries =
"ref", arrayOf(
"refid", "ref",
"SharedWith", "refid",
"fbclid", "SharedWith",
"h", "fbclid",
"_ft_", "h",
"_tn_", "_ft_",
"_xt_", "_tn_",
"bacr", "_xt_",
"frefs", "bacr",
"hc_ref", "frefs",
"loc_ref", "hc_ref",
"pn_ref" "loc_ref",
) "pn_ref"
)
val converter = listOf( val converter =
"\\3C " to "%3C", "\\3E " to "%3E", "\\23 " to "%23", "\\25 " to "%25", listOf(
"\\7B " to "%7B", "\\7D " to "%7D", "\\7C " to "%7C", "\\5C " to "%5C", "\\3C " to "%3C",
"\\5E " to "%5E", "\\7E " to "%7E", "\\5B " to "%5B", "\\5D " to "%5D", "\\3E " to "%3E",
"\\60 " to "%60", "\\3B " to "%3B", "\\2F " to "%2F", "\\3F " to "%3F", "\\23 " to "%23",
"\\3A " to "%3A", "\\40 " to "%40", "\\3D " to "%3D", "\\26 " to "%26", "\\25 " to "%25",
"\\24 " to "%24", "\\2B " to "%2B", "\\22 " to "%22", "\\2C " to "%2C", "\\7B " to "%7B",
"\\20 " to "%20" "\\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"
)
}
} }

View File

@ -23,42 +23,38 @@ import org.jsoup.nodes.Document
object BadgeParser : FrostParser<FrostBadges> by BadgeParserImpl() object BadgeParser : FrostParser<FrostBadges> by BadgeParserImpl()
data class FrostBadges( data class FrostBadges(
val feed: String?, val feed: String?,
val friends: String?, val friends: String?,
val messages: String?, val messages: String?,
val notifications: String? val notifications: String?
) : ParseData { ) : ParseData {
override val isEmpty: Boolean override val isEmpty: Boolean
get() = feed.isNullOrEmpty() && get() =
friends.isNullOrEmpty() && feed.isNullOrEmpty() &&
messages.isNullOrEmpty() && friends.isNullOrEmpty() &&
notifications.isNullOrEmpty() messages.isNullOrEmpty() &&
notifications.isNullOrEmpty()
} }
private class BadgeParserImpl : FrostParserBase<FrostBadges>(false) { private class BadgeParserImpl : FrostParserBase<FrostBadges>(false) {
// Not actually displayed // Not actually displayed
override var nameRes: Int = R.string.frost_name 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? { override fun parseImpl(doc: Document): FrostBadges? {
val header = doc.getElementById("header") ?: return null val header = doc.getElementById("header") ?: return null
if (header.select("[data-sigil=count]").isEmpty()) if (header.select("[data-sigil=count]").isEmpty()) return null
return null val (feed, requests, messages, notifications) =
val (feed, requests, messages, notifications) = listOf( listOf("feed", "requests", "messages", "notifications")
"feed", .map { "[data-sigil*=$it] [data-sigil=count]" }
"requests", .map { doc.select(it) }
"messages", .map { e -> e?.getOrNull(0)?.ownText() }
"notifications" return FrostBadges(
) feed = feed,
.map { "[data-sigil*=$it] [data-sigil=count]" } friends = requests,
.map { doc.select(it) } messages = messages,
.map { e -> e?.getOrNull(0)?.ownText() } notifications = notifications
return FrostBadges( )
feed = feed, }
friends = requests,
messages = messages,
notifications = notifications
)
}
} }

View File

@ -30,46 +30,33 @@ import org.jsoup.select.Elements
/** /**
* Created by Allan Wang on 2017-10-06. * Created by Allan Wang on 2017-10-06.
* *
* Interface for a given parser * Interface for a given parser Use cases should be attached as delegates to objects that implement
* Use cases should be attached as delegates to objects that implement this interface * this interface
* *
* In all cases, parsing will be done from a JSoup document * In all cases, parsing will be done from a JSoup document Variants accepting strings are also
* Variants accepting strings are also permitted, and they will be converted to documents accordingly * permitted, and they will be converted to documents accordingly The return type must be nonnull if
* The return type must be nonnull if no parsing errors occurred, as null signifies a parse error * no parsing errors occurred, as null signifies a parse error If null really must be allowed, use
* If null really must be allowed, use Optionals * Optionals
*/ */
interface FrostParser<out T : ParseData> { interface FrostParser<out T : ParseData> {
/** /** Name associated to parser Purely for display */
* Name associated to parser var nameRes: Int
* Purely for display
*/
var nameRes: Int
/** /** Url to request from */
* Url to request from val url: String
*/
val url: String
/** /** Call parsing with default implementation using cookie */
* Call parsing with default implementation using cookie fun parse(cookie: String?): ParseResponse<T>?
*/
fun parse(cookie: String?): ParseResponse<T>?
/** /** Call parsing with given document */
* Call parsing with given document fun parse(cookie: String?, document: Document): ParseResponse<T>?
*/
fun parse(cookie: String?, document: Document): ParseResponse<T>?
/** /** Call parsing using jsoup to fetch from given url */
* Call parsing using jsoup to fetch from given url fun parseFromUrl(cookie: String?, url: String): ParseResponse<T>?
*/
fun parseFromUrl(cookie: String?, url: String): ParseResponse<T>?
/** /** Call parsing with given data */
* Call parsing with given data fun parseFromData(cookie: String?, text: String): ParseResponse<T>?
*/
fun parseFromData(cookie: String?, text: String): ParseResponse<T>?
} }
const val FALLBACK_TIME_MOD = 1000000 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 FrostLink(val text: String, val href: String)
data class ParseResponse<out T : ParseData>(val cookie: String, val data: T) { data class ParseResponse<out T : ParseData>(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 { interface ParseData {
val isEmpty: Boolean val isEmpty: Boolean
} }
interface ParseNotification : ParseData { interface ParseNotification : ParseData {
fun getUnreadNotifications(data: CookieEntity): List<NotificationContent> fun getUnreadNotifications(data: CookieEntity): List<NotificationContent>
} }
internal fun <T> List<T>.toJsonString(tag: String, indent: Int) = StringBuilder().apply { internal fun <T> List<T>.toJsonString(tag: String, indent: Int) =
val tabs = "\t".repeat(indent) StringBuilder()
append("$tabs$tag: [\n\t$tabs") .apply {
append(this@toJsonString.joinToString("\n\t$tabs")) val tabs = "\t".repeat(indent)
append("\n$tabs]\n") append("$tabs$tag: [\n\t$tabs")
}.toString() append(this@toJsonString.joinToString("\n\t$tabs"))
append("\n$tabs]\n")
}
.toString()
/** /**
* T should have a readable toString() function * T should have a readable toString() function [redirectToText] dictates whether all data should be
* [redirectToText] dictates whether all data should be converted to text then back to document before parsing * converted to text then back to document before parsing
*/ */
internal abstract class FrostParserBase<out T : ParseData>(private val redirectToText: Boolean) : internal abstract class FrostParserBase<out T : ParseData>(private val redirectToText: Boolean) :
FrostParser<T> { FrostParser<T> {
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<T>? { final override fun parseFromData(cookie: String?, text: String): ParseResponse<T>? {
cookie ?: return null cookie ?: return null
val doc = textToDoc(text) ?: return null val doc = textToDoc(text) ?: return null
val data = parseImpl(doc) ?: return null val data = parseImpl(doc) ?: return null
return ParseResponse(cookie, data) return ParseResponse(cookie, data)
} }
final override fun parseFromUrl(cookie: String?, url: String): ParseResponse<T>? = final override fun parseFromUrl(cookie: String?, url: String): ParseResponse<T>? =
parse(cookie, frostJsoup(cookie, url)) parse(cookie, frostJsoup(cookie, url))
override fun parse(cookie: String?, document: Document): ParseResponse<T>? { override fun parse(cookie: String?, document: Document): ParseResponse<T>? {
cookie ?: return null cookie ?: return null
if (redirectToText) if (redirectToText) return parseFromData(cookie, document.toString())
return parseFromData(cookie, document.toString()) val data = parseImpl(document) ?: return null
val data = parseImpl(document) ?: return null return ParseResponse(cookie, data)
return ParseResponse(cookie, data) }
}
protected abstract fun parseImpl(doc: Document): T? protected abstract fun parseImpl(doc: Document): T?
/** /**
* Attempts to find inner <i> element with some style containing a url * Attempts to find inner <i> element with some style containing a url Returns the formatted url,
* Returns the formatted url, or an empty string if nothing was found * or an empty string if nothing was found
*/ */
protected fun Element.getInnerImgStyle(): String? = protected fun Element.getInnerImgStyle(): String? = select("i.img[style*=url]").getStyleUrl()
select("i.img[style*=url]").getStyleUrl()
protected fun Elements.getStyleUrl(): String? = protected fun Elements.getStyleUrl(): String? =
FB_CSS_URL_MATCHER.find(attr("style"))[1]?.formattedFbUrl FB_CSS_URL_MATCHER.find(attr("style"))[1]?.formattedFbUrl
protected open fun textToDoc(text: String): Document? = protected open fun textToDoc(text: String): Document? =
if (!redirectToText) Jsoup.parse(text) if (!redirectToText) Jsoup.parse(text)
else throw RuntimeException("${this::class.java.simpleName} requires text redirect but did not implement textToDoc") else
throw RuntimeException(
"${this::class.java.simpleName} requires text redirect but did not implement textToDoc"
)
protected fun parseLink(element: Element?): FrostLink? { protected fun parseLink(element: Element?): FrostLink? {
val a = element?.getElementsByTag("a")?.first() ?: return null val a = element?.getElementsByTag("a")?.first() ?: return null
return FrostLink(a.text(), a.attr("href")) return FrostLink(a.text(), a.attr("href"))
} }
} }

View File

@ -33,126 +33,128 @@ import org.jsoup.nodes.Element
/** /**
* Created by Allan Wang on 2017-10-06. * Created by Allan Wang on 2017-10-06.
* *
* In Facebook, messages are passed through scripts and loaded into view via react afterwards * In Facebook, messages are passed through scripts and loaded into view via react afterwards We can
* We can parse out the content we want directly and load it ourselves * parse out the content we want directly and load it ourselves
*
*/ */
object MessageParser : FrostParser<FrostMessages> by MessageParserImpl() { object MessageParser : FrostParser<FrostMessages> by MessageParserImpl() {
fun queryUser(cookie: String?, name: String) = fun queryUser(cookie: String?, name: String) =
parseFromUrl(cookie, "${FbItem.MESSAGES.url}/?q=${name.urlEncode()}") parseFromUrl(cookie, "${FbItem.MESSAGES.url}/?q=${name.urlEncode()}")
} }
data class FrostMessages( data class FrostMessages(
val threads: List<FrostThread>, val threads: List<FrostThread>,
val seeMore: FrostLink?, val seeMore: FrostLink?,
val extraLinks: List<FrostLink> val extraLinks: List<FrostLink>
) : ParseNotification { ) : ParseNotification {
override val isEmpty: Boolean override val isEmpty: Boolean
get() = threads.isEmpty() get() = threads.isEmpty()
override fun toString() = StringBuilder().apply { override fun toString() =
StringBuilder()
.apply {
append("FrostMessages {\n") append("FrostMessages {\n")
append(threads.toJsonString("threads", 1)) append(threads.toJsonString("threads", 1))
append("\tsee more: $seeMore\n") append("\tsee more: $seeMore\n")
append(extraLinks.toJsonString("extra links", 1)) append(extraLinks.toJsonString("extra links", 1))
append("}") append("}")
}.toString() }
.toString()
override fun getUnreadNotifications(data: CookieEntity) = override fun getUnreadNotifications(data: CookieEntity) =
threads.asSequence().filter(FrostThread::unread).map { threads
with(it) { .asSequence()
NotificationContent( .filter(FrostThread::unread)
data = data, .map {
id = id, with(it) {
href = url, NotificationContent(
title = title, data = data,
text = content ?: "", id = id,
timestamp = time, href = url,
profileUrl = img, title = title,
unread = unread text = content ?: "",
) timestamp = time,
} profileUrl = img,
}.toList() unread = unread
)
}
}
.toList()
} }
/** /**
* [id] user/thread id, or current time fallback * [id] user/thread id, or current time fallback [img] parsed url for profile img [time] time of
* [img] parsed url for profile img * message [url] link to thread [unread] true if image is unread, false otherwise [content] optional
* [time] time of message * string for thread
* [url] link to thread
* [unread] true if image is unread, false otherwise
* [content] optional string for thread
*/ */
data class FrostThread( data class FrostThread(
val id: Long, val id: Long,
val img: String?, val img: String?,
val title: String, val title: String,
val time: Long, val time: Long,
val url: String, val url: String,
val unread: Boolean, val unread: Boolean,
val content: String?, val content: String?,
val contentImgUrl: String? val contentImgUrl: String?
) )
private class MessageParserImpl : FrostParserBase<FrostMessages>(true) { private class MessageParserImpl : FrostParserBase<FrostMessages>(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? { override fun textToDoc(text: String): Document? {
var content = StringEscapeUtils.unescapeEcmaScript(text) var content = StringEscapeUtils.unescapeEcmaScript(text)
val begin = content.indexOf("id=\"threadlist_rows\"") val begin = content.indexOf("id=\"threadlist_rows\"")
if (begin <= 0) { if (begin <= 0) {
L.d { "Threadlist not found" } L.d { "Threadlist not found" }
return null return null
}
content = content.substring(begin)
val end = content.indexOf("</script>")
if (end <= 0) {
L.d { "Script tail not found" }
return null
}
content = content.substring(0, end).substringBeforeLast("</div>")
return Jsoup.parseBodyFragment("<div $content")
} }
content = content.substring(begin)
override fun parseImpl(doc: Document): FrostMessages? { val end = content.indexOf("</script>")
val threadList = doc.getElementById("threadlist_rows") ?: return null if (end <= 0) {
val threads: List<FrostThread> = L.d { "Script tail not found" }
threadList.getElementsByAttributeValueMatching( return null
"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(0, end).substringBeforeLast("</div>")
return Jsoup.parseBodyFragment("<div $content")
}
private fun parseMessage(element: Element): FrostThread? { override fun parseImpl(doc: Document): FrostMessages? {
val a = element.getElementsByTag("a").first() ?: return null val threadList = doc.getElementById("threadlist_rows") ?: return null
val abbr = element.getElementsByTag("abbr") val threads: List<FrostThread> =
val epoch = FB_EPOCH_MATCHER.find(abbr.attr("data-store"))[1]?.toLongOrNull() ?: -1L threadList
// fetch id .getElementsByAttributeValueMatching("id", ".*${FB_MESSAGE_NOTIF_ID_MATCHER.pattern}.*")
val id = FB_MESSAGE_NOTIF_ID_MATCHER.find(element.id())[1]?.toLongOrNull() .mapNotNull(this::parseMessage)
?: System.currentTimeMillis() % FALLBACK_TIME_MOD val seeMore = parseLink(doc.getElementById("see_older_threads"))
val snippet = element.select("span.snippet").firstOrNull() val extraLinks =
val content = snippet?.text()?.trim() threadList.nextElementSibling()?.select("a")?.mapNotNull(this::parseLink) ?: emptyList()
val contentImg = snippet?.select("i[style*=url]")?.getStyleUrl() return FrostMessages(threads, seeMore, extraLinks)
val img = element.getInnerImgStyle() }
return FrostThread(
id = id, private fun parseMessage(element: Element): FrostThread? {
img = img, val a = element.getElementsByTag("a").first() ?: return null
title = a.text(), val abbr = element.getElementsByTag("abbr")
time = epoch, val epoch = FB_EPOCH_MATCHER.find(abbr.attr("data-store"))[1]?.toLongOrNull() ?: -1L
url = a.attr("href").formattedFbUrl, // fetch id
unread = !element.hasClass("acw"), val id =
content = content, FB_MESSAGE_NOTIF_ID_MATCHER.find(element.id())[1]?.toLongOrNull()
contentImgUrl = contentImg ?: 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
)
}
} }

View File

@ -26,103 +26,101 @@ import com.pitchedapps.frost.services.NotificationContent
import org.jsoup.nodes.Document import org.jsoup.nodes.Document
import org.jsoup.nodes.Element import org.jsoup.nodes.Element
/** /** Created by Allan Wang on 2017-12-25. */
* Created by Allan Wang on 2017-12-25.
*
*/
object NotifParser : FrostParser<FrostNotifs> by NotifParserImpl() object NotifParser : FrostParser<FrostNotifs> by NotifParserImpl()
data class FrostNotifs( data class FrostNotifs(val notifs: List<FrostNotif>, val seeMore: FrostLink?) : ParseNotification {
val notifs: List<FrostNotif>,
val seeMore: FrostLink?
) : ParseNotification {
override val isEmpty: Boolean override val isEmpty: Boolean
get() = notifs.isEmpty() get() = notifs.isEmpty()
override fun toString() = StringBuilder().apply { override fun toString() =
StringBuilder()
.apply {
append("FrostNotifs {\n") append("FrostNotifs {\n")
append(notifs.toJsonString("notifs", 1)) append(notifs.toJsonString("notifs", 1))
append("\tsee more: $seeMore\n") append("\tsee more: $seeMore\n")
append("}") append("}")
}.toString() }
.toString()
override fun getUnreadNotifications(data: CookieEntity) = override fun getUnreadNotifications(data: CookieEntity) =
notifs.asSequence().filter(FrostNotif::unread).map { notifs
with(it) { .asSequence()
NotificationContent( .filter(FrostNotif::unread)
data = data, .map {
id = id, with(it) {
href = url, NotificationContent(
title = null, data = data,
text = content, id = id,
timestamp = time, href = url,
profileUrl = img, title = null,
unread = unread text = content,
) timestamp = time,
} profileUrl = img,
}.toList() unread = unread
)
}
}
.toList()
} }
/** /**
* [id] notif id, or current time fallback * [id] notif id, or current time fallback [img] parsed url for profile img [time] time of message
* [img] parsed url for profile img * [url] link to thread [unread] true if image is unread, false otherwise [content] optional string
* [time] time of message * for thread [timeString] text version of time from Facebook [thumbnailUrl] optional thumbnail url
* [url] link to thread * if existent
* [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( data class FrostNotif(
val id: Long, val id: Long,
val img: String?, val img: String?,
val time: Long, val time: Long,
val url: String, val url: String,
val unread: Boolean, val unread: Boolean,
val content: String, val content: String,
val timeString: String, val timeString: String,
val thumbnailUrl: String? val thumbnailUrl: String?
) )
private class NotifParserImpl : FrostParserBase<FrostNotifs>(false) { private class NotifParserImpl : FrostParserBase<FrostNotifs>(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? { override fun parseImpl(doc: Document): FrostNotifs? {
val notificationList = doc.getElementById("notifications_list") ?: return null val notificationList = doc.getElementById("notifications_list") ?: return null
val notifications = notificationList val notifications =
.getElementsByAttributeValueMatching("id", ".*${FB_NOTIF_ID_MATCHER.pattern}.*") notificationList
.mapNotNull(this::parseNotif) .getElementsByAttributeValueMatching("id", ".*${FB_NOTIF_ID_MATCHER.pattern}.*")
val seeMore = .mapNotNull(this::parseNotif)
parseLink(doc.getElementsByAttributeValue("href", "/notifications.php?more").first()) val seeMore =
return FrostNotifs(notifications, seeMore) parseLink(doc.getElementsByAttributeValue("href", "/notifications.php?more").first())
} return FrostNotifs(notifications, seeMore)
}
private fun parseNotif(element: Element): FrostNotif? { private fun parseNotif(element: Element): FrostNotif? {
val a = element.getElementsByTag("a").first() ?: return null val a = element.getElementsByTag("a").first() ?: return null
a.selectFirst("span.accessible_elem")?.remove() a.selectFirst("span.accessible_elem")?.remove()
val abbr = element.getElementsByTag("abbr") val abbr = element.getElementsByTag("abbr")
val epoch = FB_EPOCH_MATCHER.find(abbr.attr("data-store"))[1]?.toLongOrNull() ?: -1L val epoch = FB_EPOCH_MATCHER.find(abbr.attr("data-store"))[1]?.toLongOrNull() ?: -1L
// fetch id // fetch id
val id = FB_NOTIF_ID_MATCHER.find(element.id())[1]?.toLongOrNull() val id =
?: System.currentTimeMillis() % FALLBACK_TIME_MOD FB_NOTIF_ID_MATCHER.find(element.id())[1]?.toLongOrNull()
val img = element.getInnerImgStyle() ?: System.currentTimeMillis() % FALLBACK_TIME_MOD
val timeString = abbr.text() val img = element.getInnerImgStyle()
val content = val timeString = abbr.text()
a.text().replace("\u00a0", " ").removeSuffix(timeString).trim() // remove &nbsp; val content = a.text().replace("\u00a0", " ").removeSuffix(timeString).trim() // remove &nbsp;
val thumbnail = element.selectFirst("img.thumbnail")?.attr("src") val thumbnail = element.selectFirst("img.thumbnail")?.attr("src")
return FrostNotif( return FrostNotif(
id = id, id = id,
img = img, img = img,
time = epoch, time = epoch,
url = a.attr("href").formattedFbUrl, url = a.attr("href").formattedFbUrl,
unread = !element.hasClass("acw"), unread = !element.hasClass("acw"),
content = content, content = content,
timeString = timeString, timeString = timeString,
thumbnailUrl = if (thumbnail?.isNotEmpty() == true) thumbnail else null thumbnailUrl = if (thumbnail?.isNotEmpty() == true) thumbnail else null
) )
} }
} }

View File

@ -27,98 +27,91 @@ import com.pitchedapps.frost.utils.urlEncode
import org.jsoup.nodes.Document import org.jsoup.nodes.Document
import org.jsoup.nodes.Element import org.jsoup.nodes.Element
/** /** Created by Allan Wang on 2017-10-09. */
* Created by Allan Wang on 2017-10-09.
*/
object SearchParser : FrostParser<FrostSearches> by SearchParserImpl() { object SearchParser : FrostParser<FrostSearches> by SearchParserImpl() {
fun query(cookie: String?, input: String): ParseResponse<FrostSearches>? { fun query(cookie: String?, input: String): ParseResponse<FrostSearches>? {
val url = val url = "${FbItem._SEARCH_PARSE.url}/?q=${if (input.isNotBlank()) input.urlEncode() else "a"}"
"${FbItem._SEARCH_PARSE.url}/?q=${if (input.isNotBlank()) input.urlEncode() else "a"}" L._i { "Search Query $url" }
L._i { "Search Query $url" } return parseFromUrl(cookie, url)
return parseFromUrl(cookie, url) }
}
} }
enum class SearchKeys(val key: String) { enum class SearchKeys(val key: String) {
USERS("keywords_users"), USERS("keywords_users"),
EVENTS("keywords_events") EVENTS("keywords_events")
} }
data class FrostSearches(val results: List<FrostSearch>) : ParseData { data class FrostSearches(val results: List<FrostSearch>) : ParseData {
override val isEmpty: Boolean override val isEmpty: Boolean
get() = results.isEmpty() get() = results.isEmpty()
override fun toString() = StringBuilder().apply { override fun toString() =
StringBuilder()
.apply {
append("FrostSearches {\n") append("FrostSearches {\n")
append(results.toJsonString("results", 1)) append(results.toJsonString("results", 1))
append("}") append("}")
}.toString() }
.toString()
} }
/** /**
* As far as I'm aware, all links are independent, so the queries don't matter * As far as I'm aware, all links are independent, so the queries don't matter A lot of it is
* A lot of it is tracking information, which I'll strip away * tracking information, which I'll strip away Other text items are formatted for safety
* Other text items are formatted for safety
* *
* Note that it's best to create search results from [create] * Note that it's best to create search results from [create]
*/ */
data class FrostSearch(val href: String, val title: String, val description: String?) { 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 { companion object {
fun create(href: String, title: String, description: String?) = FrostSearch( fun create(href: String, title: String, description: String?) =
with(href.indexOf("?")) { if (this == -1) href else href.substring(0, this) }, FrostSearch(
title.format(), with(href.indexOf("?")) { if (this == -1) href else href.substring(0, this) },
description?.format() title.format(),
) description?.format()
} )
}
} }
private class SearchParserImpl : FrostParserBase<FrostSearches>(false) { private class SearchParserImpl : FrostParserBase<FrostSearches>(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 private val String.formattedSearchUrl: String
get() = replace(FACEBOOK_MBASIC_COM, FACEBOOK_BASE_COM) get() = replace(FACEBOOK_MBASIC_COM, FACEBOOK_BASE_COM)
override fun parseImpl(doc: Document): FrostSearches? { override fun parseImpl(doc: Document): FrostSearches? {
val container: Element = doc.getElementById("BrowseResultsContainer") val container: Element =
?: doc.getElementById("root") doc.getElementById("BrowseResultsContainer") ?: doc.getElementById("root") ?: return null
?: return null
return FrostSearches( return FrostSearches(
container.select("table[role=presentation]").mapNotNull { el -> container.select("table[role=presentation]").mapNotNull { el ->
// Our assumption is that search entries start with an image, followed by general info // Our assumption is that search entries start with an image, followed by general info
// There may be other <td />s, but we will not be parsing them // There may be other <td />s, but we will not be parsing them
// Furthermore, the <td /> entry wraps a link, containing all the necessary info // Furthermore, the <td /> entry wraps a link, containing all the necessary info
val a = el.select("td") val a = el.select("td").getOrNull(1)?.selectFirst("a") ?: return@mapNotNull null
.getOrNull(1) val url =
?.selectFirst("a") a.attr("href").takeIf { it.isNotEmpty() }?.formattedFbUrl?.formattedSearchUrl
?: return@mapNotNull null ?: return@mapNotNull null
val url = // Currently, children should all be <div /> elements, where the first entry is the
a.attr("href").takeIf { it.isNotEmpty() } // name/title
?.formattedFbUrl?.formattedSearchUrl // And the other entries are additional info.
?: return@mapNotNull null // There are also cases of nested tables, eg for the "join" button in groups.
// Currently, children should all be <div /> elements, where the first entry is the name/title // Those elements have <span /> texts, so we will filter by div to ignore those
// And the other entries are additional info. val texts =
// There are also cases of nested tables, eg for the "join" button in groups. a.children().filter { childEl: Element ->
// Those elements have <span /> texts, so we will filter by div to ignore those childEl.tagName() == "div" && childEl.hasText()
val texts = }
a.children() val title = texts.firstOrNull()?.text() ?: return@mapNotNull null
.filter { childEl: Element -> childEl.tagName() == "div" && childEl.hasText() } val info = texts.takeIf { it.size > 1 }?.last()?.text()
val title = texts.firstOrNull()?.text() ?: return@mapNotNull null L.e { a }
val info = texts.takeIf { it.size > 1 }?.last()?.text() create(href = url, title = title, description = info).also { L.e { it } }
L.e { a } }
create( )
href = url, }
title = title,
description = info
).also { L.e { it } }
}
)
}
} }

View File

@ -24,22 +24,17 @@ import okhttp3.Request
import okhttp3.logging.HttpLoggingInterceptor import okhttp3.logging.HttpLoggingInterceptor
val httpClient: OkHttpClient by lazy { val httpClient: OkHttpClient by lazy {
val builder = OkHttpClient.Builder() val builder = OkHttpClient.Builder()
if (BuildConfig.DEBUG) if (BuildConfig.DEBUG)
builder.addInterceptor( builder.addInterceptor(HttpLoggingInterceptor().setLevel(HttpLoggingInterceptor.Level.BASIC))
HttpLoggingInterceptor() builder.build()
.setLevel(HttpLoggingInterceptor.Level.BASIC)
)
builder.build()
} }
internal fun String?.requestBuilder(): Request.Builder { internal fun String?.requestBuilder(): Request.Builder {
val builder = Request.Builder() val builder = Request.Builder().header("User-Agent", USER_AGENT)
.header("User-Agent", USER_AGENT) if (this != null) builder.header("Cookie", this)
if (this != null) // .cacheControl(CacheControl.FORCE_NETWORK)
builder.header("Cookie", this) return builder
// .cacheControl(CacheControl.FORCE_NETWORK)
return builder
} }
fun Request.Builder.call(): Call = httpClient.newCall(build()) fun Request.Builder.call(): Call = httpClient.newCall(build())

View File

@ -24,23 +24,19 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeout 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? = suspend fun String.getFullSizedImageUrl(url: String, timeout: Long = 3000): String? =
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
try { try {
withTimeout(timeout) { withTimeout(timeout) {
val redirect = requestBuilder().url(url).get().call() val redirect =
.execute().body?.string() ?: return@withTimeout null requestBuilder().url(url).get().call().execute().body?.string() ?: return@withTimeout null
FB_REDIRECT_URL_MATCHER.find(redirect)[1]?.formattedFbUrl FB_REDIRECT_URL_MATCHER.find(redirect)[1]?.formattedFbUrl
} }
} catch (e: Exception) { } catch (e: Exception) {
L.e(e) { "Failed to load full size image url" } L.e(e) { "Failed to load full size image url" }
null null
}
} }
}

View File

@ -43,211 +43,199 @@ import com.pitchedapps.frost.utils.REQUEST_REFRESH
import com.pitchedapps.frost.utils.REQUEST_TEXT_ZOOM import com.pitchedapps.frost.utils.REQUEST_TEXT_ZOOM
import com.pitchedapps.frost.utils.frostEvent import com.pitchedapps.frost.utils.frostEvent
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject
import kotlin.coroutines.CoroutineContext
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.isActive import kotlinx.coroutines.isActive
import javax.inject.Inject
import kotlin.coroutines.CoroutineContext
/** /**
* Created by Allan Wang on 2017-11-07. * Created by Allan Wang on 2017-11-07.
* *
* All fragments pertaining to the main view * All fragments pertaining to the main view Must be attached to activities implementing
* Must be attached to activities implementing [MainActivityContract] * [MainActivityContract]
*/ */
@AndroidEntryPoint @AndroidEntryPoint
abstract class BaseFragment : abstract class BaseFragment : Fragment(), CoroutineScope, FragmentContract, DynamicUiContract {
Fragment(),
CoroutineScope,
FragmentContract,
DynamicUiContract {
companion object { companion object {
private const val ARG_POSITION = "arg_position" private const val ARG_POSITION = "arg_position"
private const val ARG_VALID = "arg_valid" private const val ARG_VALID = "arg_valid"
internal operator fun invoke( internal operator fun invoke(
base: () -> BaseFragment, base: () -> BaseFragment,
prefs: Prefs, prefs: Prefs,
useFallback: Boolean, useFallback: Boolean,
data: FbItem, data: FbItem,
position: Int position: Int
): BaseFragment { ): BaseFragment {
val fragment = if (useFallback) WebFragment() else base() val fragment = if (useFallback) WebFragment() else base()
val d = if (data == FbItem.FEED) FeedSort(prefs.feedSort).item else data val d = if (data == FbItem.FEED) FeedSort(prefs.feedSort).item else data
fragment.withArguments( fragment.withArguments(ARG_URL to d.url, ARG_POSITION to position)
ARG_URL to d.url, d.put(fragment.requireArguments())
ARG_POSITION to position return fragment
) }
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 override var firstLoad: Boolean = true
protected lateinit var mainContract: MainActivityContract private var onCreateRunnable: ((FragmentContract) -> Unit)? = null
@Inject override var content: FrostContentParent? = null
protected lateinit var fbCookie: FbCookie
@Inject protected abstract val layoutRes: Int
protected lateinit var prefs: Prefs
@Inject override fun onCreate(savedInstanceState: Bundle?) {
protected lateinit var themeProvider: ThemeProvider super.onCreate(savedInstanceState)
job = SupervisorJob()
firstLoad = true
}
open lateinit var job: Job final override fun onCreateView(
override val coroutineContext: CoroutineContext inflater: LayoutInflater,
get() = ContextHelper.dispatcher + job 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 fun onViewCreated(view: View, savedInstanceState: Bundle?) {
override val baseEnum: FbItem by lazy { FbItem[arguments]!! } super.onViewCreated(view, savedInstanceState)
override val position: Int by lazy { requireArguments().getInt(ARG_POSITION) } onCreateRunnable?.invoke(this)
onCreateRunnable = null
firstLoadRequest()
attach(mainContract)
}
override var valid: Boolean override fun setUserVisibleHint(isVisibleToUser: Boolean) {
get() = requireArguments().getBoolean(ARG_VALID, true) super.setUserVisibleHint(isVisibleToUser)
set(value) { firstLoadRequest()
if (!isActive || value || this is WebFragment) return }
requireArguments().putBoolean(ARG_VALID, value)
frostEvent(
"Native Fallback",
"Item" to baseEnum.name
)
mainContract.reloadFragment(this)
}
override var firstLoad: Boolean = true override fun firstLoadRequest() {
private var onCreateRunnable: ((FragmentContract) -> Unit)? = null val core = core ?: return
if (userVisibleHint && isVisible && firstLoad) {
override var content: FrostContentParent? = null core.reloadBase(true)
firstLoad = false
protected abstract val layoutRes: Int
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
job = SupervisorJob()
firstLoad = true
} }
}
final override fun onCreateView( override fun post(action: (fragment: FragmentContract) -> Unit) {
inflater: LayoutInflater, onCreateRunnable = action
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 onViewCreated(view: View, savedInstanceState: Bundle?) { override fun setTitle(title: String) {
super.onViewCreated(view, savedInstanceState) mainContract.setTitle(title)
onCreateRunnable?.invoke(this) }
onCreateRunnable = null
firstLoadRequest()
attach(mainContract)
}
override fun setUserVisibleHint(isVisibleToUser: Boolean) { override fun attach(contract: MainActivityContract) {
super.setUserVisibleHint(isVisibleToUser) contract.fragmentFlow
firstLoadRequest() .flowWithLifecycle(viewLifecycleOwner.lifecycle)
} .onEach { flag ->
when (flag) {
override fun firstLoadRequest() { REQUEST_REFRESH -> {
val core = core ?: return core?.apply {
if (userVisibleHint && isVisible && firstLoad) { clearHistory()
core.reloadBase(true) firstLoad = true
firstLoad = false firstLoadRequest()
}
}
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)
} }
} else { }
setIcon(iicon, themeProvider.iconColor) position -> {
show() 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() { override fun onDestroyView() {
super.onDestroyView() super.onDestroyView()
L.i { "Fragment on destroy $position ${hashCode()}" } L.i { "Fragment on destroy $position ${hashCode()}" }
content?.destroy() content?.destroy()
content = null content = null
} }
override fun onDestroy() { override fun onDestroy() {
job.cancel() job.cancel()
super.onDestroy() super.onDestroy()
} }
override fun reloadTheme() { override fun reloadTheme() {
reloadThemeSelf() reloadThemeSelf()
content?.reloadTextSize() content?.reloadTextSize()
} }
override fun reloadThemeSelf() { override fun reloadThemeSelf() {
// intentionally blank // intentionally blank
} }
override fun reloadTextSize() { override fun reloadTextSize() {
reloadTextSizeSelf() reloadTextSizeSelf()
content?.reloadTextSize() content?.reloadTextSize()
} }
override fun reloadTextSizeSelf() { override fun reloadTextSizeSelf() {
// intentionally blank // 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
} }

View File

@ -23,82 +23,65 @@ import com.pitchedapps.frost.contracts.MainActivityContract
import com.pitchedapps.frost.contracts.MainFabContract import com.pitchedapps.frost.contracts.MainFabContract
import com.pitchedapps.frost.views.FrostRecyclerView 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 { interface FragmentContract : FrostContentContainer {
val content: FrostContentParent? val content: FrostContentParent?
/** /**
* Defines whether the fragment is valid in the viewpager * Defines whether the fragment is valid in the viewpager or if it needs to be recreated May be
* or if it needs to be recreated * called from any thread to toggle status. Note that calls beyond the fragment lifecycle will be
* May be called from any thread to toggle status. * ignored
* Note that calls beyond the fragment lifecycle will be ignored */
*/ var valid: Boolean
var valid: Boolean
/** /** Helper to retrieve the core from [content] */
* Helper to retrieve the core from [content] val core: FrostContentCore?
*/ get() = content?.core
val core: FrostContentCore?
get() = content?.core
/** /** Specifies position in Activity's viewpager */
* Specifies position in Activity's viewpager val position: Int
*/
val position: Int
/** /**
* Specifies whether if current load * Specifies whether if current load will be fragment's first load
* will be fragment's first load *
* * Defaults to true
* Defaults to true */
*/ var firstLoad: Boolean
var firstLoad: Boolean
/** /**
* Called when the fragment is first visible * Called when the fragment is first visible Typically, if [firstLoad] is true, the fragment
* Typically, if [firstLoad] is true, * should call [reload] and make [firstLoad] false
* the fragment should call [reload] and make [firstLoad] false */
*/ fun firstLoadRequest()
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 */
* Single callable action to be executed upon creation fun post(action: (fragment: FragmentContract) -> Unit)
* 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. */
* Call whenever a fragment is attached so that it may listen fun attach(contract: MainActivityContract)
* to activity emissions.
*/
fun attach(contract: MainActivityContract)
/* /*
* ----------------------------------------- * -----------------------------------------
* Delegates * Delegates
* ----------------------------------------- * -----------------------------------------
*/ */
fun onBackPressed(): Boolean fun onBackPressed(): Boolean
fun onTabClick() fun onTabClick()
} }
interface RecyclerContentContract { interface RecyclerContentContract {
fun bind(recyclerView: FrostRecyclerView) fun bind(recyclerView: FrostRecyclerView)
/** /**
* Completely handle data reloading, within a non-ui thread * Completely handle data reloading, within a non-ui thread The progress function allows optional
* The progress function allows optional emission of progress values (between 0 and 100) * emission of progress values (between 0 and 100) and can be called from any thread. Returns
* and can be called from any thread. * [true] for success, [false] otherwise
* Returns [true] for success, [false] otherwise */
*/ suspend fun reload(progress: (Int) -> Unit): Boolean
suspend fun reload(progress: (Int) -> Unit): Boolean
} }

View File

@ -32,119 +32,113 @@ import com.pitchedapps.frost.views.FrostRecyclerView
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
/** /** Created by Allan Wang on 27/12/17. */
* Created by Allan Wang on 27/12/17.
*/
abstract class RecyclerFragment<T, Item : GenericItem> : BaseFragment(), RecyclerContentContract { abstract class RecyclerFragment<T, Item : GenericItem> : BaseFragment(), RecyclerContentContract {
override val layoutRes: Int = R.layout.view_content_recycler override val layoutRes: Int = R.layout.view_content_recycler
abstract val adapter: ModelAdapter<T, Item> abstract val adapter: ModelAdapter<T, Item>
override fun firstLoadRequest() { override fun firstLoadRequest() {
val core = core ?: return val core = core ?: return
if (firstLoad) { if (firstLoad) {
core.reloadBase(true) core.reloadBase(true)
firstLoad = false 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 = protected abstract suspend fun reloadImpl(progress: (Int) -> Unit): List<T>?
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<T>?
} }
abstract class GenericRecyclerFragment<T, Item : GenericItem> : RecyclerFragment<T, Item>() { abstract class GenericRecyclerFragment<T, Item : GenericItem> : RecyclerFragment<T, Item>() {
abstract fun mapper(data: T): Item abstract fun mapper(data: T): Item
override val adapter: ModelAdapter<T, Item> = ModelAdapter { this.mapper(it) } override val adapter: ModelAdapter<T, Item> = ModelAdapter { this.mapper(it) }
final override fun bind(recyclerView: FrostRecyclerView) { final override fun bind(recyclerView: FrostRecyclerView) {
recyclerView.adapter = getAdapter() recyclerView.adapter = getAdapter()
recyclerView.onReloadClear = { adapter.clear() } recyclerView.onReloadClear = { adapter.clear() }
bindImpl(recyclerView) bindImpl(recyclerView)
} }
/** /**
* Anything to call for one time bindings * Anything to call for one time bindings At this stage, all adapters will have FastAdapter
* At this stage, all adapters will have FastAdapter references * references
*/ */
open fun bindImpl(recyclerView: FrostRecyclerView) = Unit open fun bindImpl(recyclerView: FrostRecyclerView) = Unit
/** /** Create the fast adapter to bind to the recyclerview */
* Create the fast adapter to bind to the recyclerview open fun getAdapter(): GenericFastAdapter = fastAdapter(this.adapter)
*/
open fun getAdapter(): GenericFastAdapter = fastAdapter(this.adapter)
} }
abstract class FrostParserFragment<T : ParseData, Item : GenericItem> : abstract class FrostParserFragment<T : ParseData, Item : GenericItem> :
RecyclerFragment<Item, Item>() { RecyclerFragment<Item, Item>() {
/** /** The parser to make this all happen */
* The parser to make this all happen abstract val parser: FrostParser<T>
*/
abstract val parser: FrostParser<T>
open fun getDoc(cookie: String?) = frostJsoup(cookie, parser.url) open fun getDoc(cookie: String?) = frostJsoup(cookie, parser.url)
abstract fun toItems(response: ParseResponse<T>): List<Item> abstract fun toItems(response: ParseResponse<T>): List<Item>
override val adapter: ItemAdapter<Item> = ItemAdapter() override val adapter: ItemAdapter<Item> = ItemAdapter()
final override fun bind(recyclerView: FrostRecyclerView) { final override fun bind(recyclerView: FrostRecyclerView) {
recyclerView.adapter = getAdapter() recyclerView.adapter = getAdapter()
recyclerView.onReloadClear = { adapter.clear() } recyclerView.onReloadClear = { adapter.clear() }
bindImpl(recyclerView) bindImpl(recyclerView)
} }
/** /**
* Anything to call for one time bindings * Anything to call for one time bindings At this stage, all adapters will have FastAdapter
* At this stage, all adapters will have FastAdapter references * references
*/ */
open fun bindImpl(recyclerView: FrostRecyclerView) = Unit open fun bindImpl(recyclerView: FrostRecyclerView) = Unit
/** /** Create the fast adapter to bind to the recyclerview */
* Create the fast adapter to bind to the recyclerview open fun getAdapter(): GenericFastAdapter = fastAdapter(this.adapter)
*/
open fun getAdapter(): GenericFastAdapter = fastAdapter(this.adapter)
override suspend fun reloadImpl(progress: (Int) -> Unit): List<Item>? = override suspend fun reloadImpl(progress: (Int) -> Unit): List<Item>? =
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
progress(10) progress(10)
val cookie = fbCookie.webCookie val cookie = fbCookie.webCookie
val doc = getDoc(cookie) val doc = getDoc(cookie)
progress(60) progress(60)
val response = try { val response =
parser.parse(cookie, doc) try {
} catch (ignored: Exception) { parser.parse(cookie, doc)
null } 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
} }
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
}
} }

View File

@ -27,19 +27,22 @@ import com.pitchedapps.frost.views.FrostRecyclerView
/** /**
* Created by Allan Wang on 27/12/17. * 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<FrostNotifs, NotificationIItem>() { class NotificationFragment : FrostParserFragment<FrostNotifs, NotificationIItem>() {
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<FrostNotifs>): List<NotificationIItem> = override fun toItems(response: ParseResponse<FrostNotifs>): List<NotificationIItem> =
response.data.notifs.map { NotificationIItem(it, response.cookie, themeProvider) } response.data.notifs.map { NotificationIItem(it, response.cookie, themeProvider) }
override fun bindImpl(recyclerView: FrostRecyclerView) { override fun bindImpl(recyclerView: FrostRecyclerView) {
NotificationIItem.bindEvents(adapter, fbCookie, prefs, themeProvider) NotificationIItem.bindEvents(adapter, fbCookie, prefs, themeProvider)
} }
} }

View File

@ -31,34 +31,30 @@ import com.pitchedapps.frost.web.FrostWebViewClientMessenger
/** /**
* Created by Allan Wang on 27/12/17. * Created by Allan Wang on 27/12/17.
* *
* Basic webfragment * Basic webfragment Do not extend as this is always a fallback
* Do not extend as this is always a fallback
*/ */
class WebFragment : BaseFragment() { 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 */
* Given a webview, output a client fun client(web: FrostWebView) =
*/ when (baseEnum) {
fun client(web: FrostWebView) = when (baseEnum) { FbItem.MESSENGER -> FrostWebViewClientMessenger(web)
FbItem.MESSENGER -> FrostWebViewClientMessenger(web) FbItem.MENU -> FrostWebViewClientMenu(web)
FbItem.MENU -> FrostWebViewClientMenu(web) else -> FrostWebViewClient(web)
else -> FrostWebViewClient(web)
} }
override fun updateFab(contract: MainFabContract) { override fun updateFab(contract: MainFabContract) {
val web = core as? WebView val web = core as? WebView
if (web == null) { if (web == null) {
L.e { "Webview not found in fragment $baseEnum" } L.e { "Webview not found in fragment $baseEnum" }
return super.updateFab(contract) 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)
} }
if (baseEnum.isFeed && prefs.showCreateFab) {
contract.showFab(GoogleMaterial.Icon.gmd_edit) { JsActions.CREATE_POST.inject(web, prefs) }
return
}
super.updateFab(contract)
}
} }

View File

@ -27,50 +27,51 @@ import com.bumptech.glide.load.resource.bitmap.CircleCrop
import com.bumptech.glide.module.AppGlideModule import com.bumptech.glide.module.AppGlideModule
import com.bumptech.glide.request.RequestOptions import com.bumptech.glide.request.RequestOptions
import com.pitchedapps.frost.facebook.FbCookie import com.pitchedapps.frost.facebook.FbCookie
import javax.inject.Inject
import okhttp3.Interceptor import okhttp3.Interceptor
import okhttp3.Response import okhttp3.Response
import javax.inject.Inject
/** /**
* Created by Allan Wang on 28/12/17. * Created by Allan Wang on 28/12/17.
* *
* Collection of transformations * Collection of transformations Each caller will generate a new one upon request
* Each caller will generate a new one upon request
*/ */
object FrostGlide { object FrostGlide {
val circleCrop val circleCrop
get() = CircleCrop() get() = CircleCrop()
} }
fun <T> RequestBuilder<T>.transform(vararg transformation: BitmapTransformation): RequestBuilder<T> = fun <T> RequestBuilder<T>.transform(
when (transformation.size) { vararg transformation: BitmapTransformation
0 -> this ): RequestBuilder<T> =
1 -> apply(RequestOptions.bitmapTransform(transformation[0])) when (transformation.size) {
else -> apply(RequestOptions.bitmapTransform(MultiTransformation(*transformation))) 0 -> this
} 1 -> apply(RequestOptions.bitmapTransform(transformation[0]))
else -> apply(RequestOptions.bitmapTransform(MultiTransformation(*transformation)))
}
@GlideModule @GlideModule
class FrostGlideModule : AppGlideModule() { class FrostGlideModule : AppGlideModule() {
override fun registerComponents(context: Context, glide: Glide, registry: Registry) { override fun registerComponents(context: Context, glide: Glide, registry: Registry) {
// registry.replace(GlideUrl::class.java, // registry.replace(GlideUrl::class.java,
// InputStream::class.java, // InputStream::class.java,
// OkHttpUrlLoader.Factory(getFrostHttpClient())) // OkHttpUrlLoader.Factory(getFrostHttpClient()))
// registry.prepend(HdImageMaybe::class.java, InputStream::class.java, HdImageLoadingFactory()) // registry.prepend(HdImageMaybe::class.java, InputStream::class.java,
} // HdImageLoadingFactory())
}
} }
// private fun getFrostHttpClient(): OkHttpClient = // private fun getFrostHttpClient(): OkHttpClient =
// OkHttpClient.Builder().addInterceptor(FrostCookieInterceptor()).build() // OkHttpClient.Builder().addInterceptor(FrostCookieInterceptor()).build()
class FrostCookieInterceptor @Inject internal constructor( class FrostCookieInterceptor @Inject internal constructor(private val fbCookie: FbCookie) :
private val fbCookie: FbCookie Interceptor {
) : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response { override fun intercept(chain: Interceptor.Chain): Response {
val origRequest = chain.request() val origRequest = chain.request()
val cookie = fbCookie.webCookie ?: return chain.proceed(origRequest) val cookie = fbCookie.webCookie ?: return chain.proceed(origRequest)
val request = origRequest.newBuilder().addHeader("Cookie", cookie).build() val request = origRequest.newBuilder().addHeader("Cookie", cookie).build()
return chain.proceed(request) return chain.proceed(request)
} }
} }

View File

@ -32,101 +32,86 @@ import com.pitchedapps.frost.injectors.ThemeProvider
import com.pitchedapps.frost.prefs.Prefs import com.pitchedapps.frost.prefs.Prefs
import com.pitchedapps.frost.utils.launchWebOverlay 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 { interface ClickableIItemContract {
val url: String? val url: String?
fun click(context: Context, fbCookie: FbCookie, prefs: Prefs) { fun click(context: Context, fbCookie: FbCookie, prefs: Prefs) {
val url = url ?: return val url = url ?: return
context.launchWebOverlay(url, fbCookie, prefs) context.launchWebOverlay(url, fbCookie, prefs)
} }
companion object { companion object {
fun bindEvents(adapter: IAdapter<GenericItem>, fbCookie: FbCookie, prefs: Prefs) { fun bindEvents(adapter: IAdapter<GenericItem>, fbCookie: FbCookie, prefs: Prefs) {
adapter.fastAdapter?.apply { adapter.fastAdapter?.apply {
selectExtension { selectExtension { isSelectable = false }
isSelectable = false onClickListener = { v, _, item, _ ->
} if (item is ClickableIItemContract) {
onClickListener = { v, _, item, _ -> item.click(v!!.context, fbCookie, prefs)
if (item is ClickableIItemContract) { true
item.click(v!!.context, fbCookie, prefs) } else false
true
} else
false
}
}
} }
}
} }
}
} }
/** /** Generic header item Not clickable with an accent color */
* Generic header item
* Not clickable with an accent color
*/
open class HeaderIItem( open class HeaderIItem(
val text: String?, val text: String?,
itemId: Int = R.layout.iitem_header, itemId: Int = R.layout.iitem_header,
private val themeProvider: ThemeProvider private val themeProvider: ThemeProvider
) : KauIItem<HeaderIItem.ViewHolder>( ) :
KauIItem<HeaderIItem.ViewHolder>(
R.layout.iitem_header, R.layout.iitem_header,
{ ViewHolder(it, themeProvider) }, { ViewHolder(it, themeProvider) },
itemId itemId
) { ) {
class ViewHolder( class ViewHolder(itemView: View, private val themeProvider: ThemeProvider) :
itemView: View, FastAdapter.ViewHolder<HeaderIItem>(itemView) {
private val themeProvider: ThemeProvider
) : FastAdapter.ViewHolder<HeaderIItem>(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<Any>) { override fun bindView(item: HeaderIItem, payloads: List<Any>) {
text.setTextColor(themeProvider.accentColor) text.setTextColor(themeProvider.accentColor)
text.text = item.text text.text = item.text
text.setBackgroundColor(themeProvider.nativeBgColor) text.setBackgroundColor(themeProvider.nativeBgColor)
}
override fun unbindView(item: HeaderIItem) {
text.text = null
}
} }
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( open class TextIItem(
val text: String?, val text: String?,
override val url: String?, override val url: String?,
itemId: Int = R.layout.iitem_text, itemId: Int = R.layout.iitem_text,
private val themeProvider: ThemeProvider private val themeProvider: ThemeProvider
) : KauIItem<TextIItem.ViewHolder>(R.layout.iitem_text, { ViewHolder(it, themeProvider) }, itemId), ) :
ClickableIItemContract { KauIItem<TextIItem.ViewHolder>(R.layout.iitem_text, { ViewHolder(it, themeProvider) }, itemId),
ClickableIItemContract {
class ViewHolder( class ViewHolder(itemView: View, private val themeProvider: ThemeProvider) :
itemView: View, FastAdapter.ViewHolder<TextIItem>(itemView) {
private val themeProvider: ThemeProvider
) : FastAdapter.ViewHolder<TextIItem>(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<Any>) { override fun bindView(item: TextIItem, payloads: List<Any>) {
text.setTextColor(themeProvider.textColor) text.setTextColor(themeProvider.textColor)
text.text = item.text text.text = item.text
text.background = text.background =
createSimpleRippleDrawable(themeProvider.bgColor, themeProvider.nativeBgColor) createSimpleRippleDrawable(themeProvider.bgColor, themeProvider.nativeBgColor)
}
override fun unbindView(item: TextIItem) {
text.text = null
}
} }
override fun unbindView(item: TextIItem) {
text.text = null
}
}
} }

View File

@ -41,118 +41,107 @@ import com.pitchedapps.frost.prefs.Prefs
import com.pitchedapps.frost.utils.isIndependent import com.pitchedapps.frost.utils.isIndependent
import com.pitchedapps.frost.utils.launchWebOverlay import com.pitchedapps.frost.utils.launchWebOverlay
/** /** Created by Allan Wang on 27/12/17. */
* Created by Allan Wang on 27/12/17.
*/
class NotificationIItem( class NotificationIItem(
val notification: FrostNotif, val notification: FrostNotif,
val cookie: String, val cookie: String,
private val themeProvider: ThemeProvider private val themeProvider: ThemeProvider
) : KauIItem<NotificationIItem.ViewHolder>( ) :
R.layout.iitem_notification, { ViewHolder(it, themeProvider) } KauIItem<NotificationIItem.ViewHolder>(
) { R.layout.iitem_notification,
{ ViewHolder(it, themeProvider) }
) {
companion object { companion object {
fun bindEvents( fun bindEvents(
adapter: ItemAdapter<NotificationIItem>, adapter: ItemAdapter<NotificationIItem>,
fbCookie: FbCookie, fbCookie: FbCookie,
prefs: Prefs, prefs: Prefs,
themeProvider: ThemeProvider themeProvider: ThemeProvider
) { ) {
adapter.fastAdapter?.apply { adapter.fastAdapter?.apply {
selectExtension { selectExtension { isSelectable = false }
isSelectable = false onClickListener = { v, _, item, position ->
} val notif = item.notification
onClickListener = { v, _, item, position -> if (notif.unread) {
val notif = item.notification adapter.set(
if (notif.unread) { position,
adapter.set( NotificationIItem(notif.copy(unread = false), item.cookie, themeProvider)
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<NotificationIItem> by lazy(::Diff)
}
private class Diff : DiffCallback<NotificationIItem> {
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<NotificationIItem>(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<Any>) {
val notif = item.notification
frame.background = createSimpleRippleDrawable(
themeProvider.textColor,
themeProvider.nativeBgColor(notif.unread)
) )
content.setTextColor(themeProvider.textColor) }
date.setTextColor(themeProvider.textColor.withAlpha(150)) // TODO temp fix. If url is dependent, we cannot load it directly
v!!
val glide = glide .context
glide.load(notif.img) .launchWebOverlay(
.transform(FrostGlide.circleCrop) if (notif.url.isIndependent) notif.url else FbItem.NOTIFICATIONS.url,
.into(avatar) fbCookie,
if (notif.thumbnailUrl != null) prefs
glide.load(notif.thumbnailUrl).into(thumbnail.visible()) )
true
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 see if necessary
val DIFF: DiffCallback<NotificationIItem> by lazy(::Diff)
}
private class Diff : DiffCallback<NotificationIItem> {
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<NotificationIItem>(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<Any>) {
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
}
}
} }

View File

@ -31,41 +31,33 @@ import com.pitchedapps.frost.R
import com.pitchedapps.frost.facebook.FbItem import com.pitchedapps.frost.facebook.FbItem
import com.pitchedapps.frost.injectors.ThemeProvider 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) : class TabIItem(val item: FbItem, private val themeProvider: ThemeProvider) :
KauIItem<TabIItem.ViewHolder>( KauIItem<TabIItem.ViewHolder>(R.layout.iitem_tab_preview, { ViewHolder(it, themeProvider) }),
R.layout.iitem_tab_preview, IDraggable {
{ ViewHolder(it, themeProvider) }
),
IDraggable {
override val isDraggable: Boolean = true override val isDraggable: Boolean = true
class ViewHolder( class ViewHolder(itemView: View, private val themeProvider: ThemeProvider) :
itemView: View, FastAdapter.ViewHolder<TabIItem>(itemView) {
private val themeProvider: ThemeProvider
) : FastAdapter.ViewHolder<TabIItem>(itemView) {
val image: ImageView by bindView(R.id.image) val image: ImageView by bindView(R.id.image)
val text: TextView by bindView(R.id.text) val text: TextView by bindView(R.id.text)
override fun bindView(item: TabIItem, payloads: List<Any>) { override fun bindView(item: TabIItem, payloads: List<Any>) {
val isInToolbar = adapterPosition < 4 val isInToolbar = adapterPosition < 4
val color = if (isInToolbar) themeProvider.iconColor else themeProvider.textColor val color = if (isInToolbar) themeProvider.iconColor else themeProvider.textColor
image.setIcon(item.item.icon, 20, color) image.setIcon(item.item.icon, 20, color)
if (isInToolbar) if (isInToolbar) text.invisible()
text.invisible() else {
else { text.visible().setText(item.item.titleId)
text.visible().setText(item.item.titleId) text.setTextColor(color.withAlpha(200))
text.setTextColor(color.withAlpha(200)) }
}
}
override fun unbindView(item: TabIItem) {
image.setImageDrawable(null)
text.visible().text = null
}
} }
override fun unbindView(item: TabIItem) {
image.setImageDrawable(null)
text.visible().text = null
}
}
} }

View File

@ -19,23 +19,24 @@ package com.pitchedapps.frost.injectors
import android.webkit.WebView import android.webkit.WebView
import com.pitchedapps.frost.prefs.Prefs import com.pitchedapps.frost.prefs.Prefs
/** /** Small misc inline css assets */
* Small misc inline css assets
*/
enum class CssAsset(private val content: String) : InjectorContract { 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) * 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}") Menu(
; "#bookmarks_flyout{margin-top:0 !important}#m_news_feed_stream,#MComposer{display:none !important}"
);
val injector: JsInjector by lazy { val injector: JsInjector by lazy {
JsBuilder().css(content).single("css-small-assets-$name").build() JsBuilder().css(content).single("css-small-assets-$name").build()
} }
override fun inject(webView: WebView, prefs: Prefs) { override fun inject(webView: WebView, prefs: Prefs) {
injector.inject(webView, prefs) injector.inject(webView, prefs)
} }
} }

View File

@ -25,41 +25,34 @@ import com.pitchedapps.frost.prefs.Prefs
* List of elements to hide * List of elements to hide
*/ */
enum class CssHider(private vararg val items: String) : InjectorContract { enum class CssHider(private vararg val items: String) : InjectorContract {
CORE("[data-sigil=m_login_upsell]", "[role=progressbar]"), CORE("[data-sigil=m_login_upsell]", "[role=progressbar]"),
HEADER( HEADER(
"#header:not(.mFuturePageHeader):not(.titled)", "#header:not(.mFuturePageHeader):not(.titled)",
"#mJewelNav", "#mJewelNav",
"[data-sigil=MTopBlueBarHeader]", "[data-sigil=MTopBlueBarHeader]",
"#header-notices", "#header-notices",
"[data-sigil*=m-promo-jewel-header]" "[data-sigil*=m-promo-jewel-header]"
), ),
ADS( ADS("article[data-xt*=sponsor]", "article[data-store*=sponsor]"),
"article[data-xt*=sponsor]", PEOPLE_YOU_MAY_KNOW("article._d2r"),
"article[data-store*=sponsor]" SUGGESTED_GROUPS("article[data-ft*=\"ei\":]"),
), COMPOSER("#MComposer"),
PEOPLE_YOU_MAY_KNOW("article._d2r"), MESSENGER("._s15", "[data-testid=info_panel]", "js_i"),
SUGGESTED_GROUPS("article[data-ft*=\"ei\":]"), NON_RECENT("article:not([data-store*=actor_name])"),
COMPOSER("#MComposer"), STORIES(
MESSENGER("._s15", "[data-testid=info_panel]", "js_i"), "#MStoriesTray",
NON_RECENT("article:not([data-store*=actor_name])"), // Sub element with just the tray; title is not a part of this
STORIES( "[data-testid=story_tray]"
"#MStoriesTray", ),
// Sub element with just the tray; title is not a part of this POST_ACTIONS("footer [data-sigil=\"ufi-inline-actions\"]"),
"[data-testid=story_tray]" POST_REACTIONS("footer [data-sigil=\"reactions-bling-bar\"]");
),
POST_ACTIONS(
"footer [data-sigil=\"ufi-inline-actions\"]"
),
POST_REACTIONS(
"footer [data-sigil=\"reactions-bling-bar\"]"
)
;
val injector: JsInjector by lazy { val injector: JsInjector by lazy {
JsBuilder().css("${items.joinToString(separator = ",")}{display:none !important}") JsBuilder()
.single("css-hider-$name").build() .css("${items.joinToString(separator = ",")}{display:none !important}")
} .single("css-hider-$name")
.build()
}
override fun inject(webView: WebView, prefs: Prefs) = override fun inject(webView: WebView, prefs: Prefs) = injector.inject(webView, prefs)
injector.inject(webView, prefs)
} }

View File

@ -26,27 +26,26 @@ import com.pitchedapps.frost.prefs.Prefs
* Collection of short js functions that are embedded directly * Collection of short js functions that are embedded directly
*/ */
enum class JsActions(body: String) : InjectorContract { enum class JsActions(body: String) : InjectorContract {
/** /**
* Redirects to login activity if create account is found * Redirects to login activity if create account is found see
* see [com.pitchedapps.frost.web.FrostJSI.loadLogin] * [com.pitchedapps.frost.web.FrostJSI.loadLogin]
*/ */
LOGIN_CHECK("document.getElementById('signup-button')&&Frost.loadLogin();"), LOGIN_CHECK("document.getElementById('signup-button')&&Frost.loadLogin();"),
BASE_HREF("""document.write("<base href='$FB_URL_BASE'/>");"""), BASE_HREF("""document.write("<base href='$FB_URL_BASE'/>");"""),
FETCH_BODY("""setTimeout(function(){var e=document.querySelector("main");e||(e=document.querySelector("body")),Frost.handleHtml(e.outerHTML)},1e2);"""), FETCH_BODY(
RETURN_BODY("return(document.getElementsByTagName('html')[0].innerHTML);"), """setTimeout(function(){var e=document.querySelector("main");e||(e=document.querySelector("body")),Frost.handleHtml(e.outerHTML)},1e2);"""
CREATE_POST(clickBySelector("#MComposer [onclick]")), ),
// CREATE_MSG(clickBySelector("a[rel=dialog]")), RETURN_BODY("return(document.getElementsByTagName('html')[0].innerHTML);"),
/** CREATE_POST(clickBySelector("#MComposer [onclick]")),
* Used as a pseudoinjector for maybe functions // CREATE_MSG(clickBySelector("a[rel=dialog]")),
*/ /** Used as a pseudoinjector for maybe functions */
EMPTY(""); EMPTY("");
val function = "(function(){$body})();" val function = "(function(){$body})();"
override fun inject(webView: WebView, prefs: Prefs) = override fun inject(webView: WebView, prefs: Prefs) = JsInjector(function).inject(webView, prefs)
JsInjector(function).inject(webView, prefs)
} }
@Suppress("NOTHING_TO_INLINE") @Suppress("NOTHING_TO_INLINE")
private inline fun clickBySelector(selector: String): String = private inline fun clickBySelector(selector: String): String =
"""document.querySelector("$selector").click()""" """document.querySelector("$selector").click()"""

View File

@ -22,43 +22,49 @@ import androidx.annotation.VisibleForTesting
import ca.allanwang.kau.kotlin.lazyContext import ca.allanwang.kau.kotlin.lazyContext
import com.pitchedapps.frost.prefs.Prefs import com.pitchedapps.frost.prefs.Prefs
import com.pitchedapps.frost.utils.L import com.pitchedapps.frost.utils.L
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.BufferedReader import java.io.BufferedReader
import java.io.FileNotFoundException import java.io.FileNotFoundException
import java.util.Locale import java.util.Locale
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
/** /**
* Created by Allan Wang on 2017-05-31. * Created by Allan Wang on 2017-05-31. Mapping of the available assets The enum name must match the
* Mapping of the available assets * css file name
* The enum name must match the css file name
*/ */
enum class JsAssets(private val singleLoad: Boolean = true) : InjectorContract { 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, MENU,
DOCUMENT_WATCHER, HORIZONTAL_SCROLLING, AUTO_RESIZE_TEXTAREA(singleLoad = false), SCROLL_STOP, 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 @VisibleForTesting internal val file = "${name.toLowerCase(Locale.CANADA)}.js"
internal val file = "${name.toLowerCase(Locale.CANADA)}.js" private val injector = lazyContext {
private val injector = lazyContext { try {
try { val content = it.assets.open("js/$file").bufferedReader().use(BufferedReader::readText)
val content = it.assets.open("js/$file").bufferedReader().use(BufferedReader::readText) JsBuilder().js(content).run { if (singleLoad) single(name) else this }.build()
JsBuilder().js(content).run { if (singleLoad) single(name) else this }.build() } catch (e: FileNotFoundException) {
} catch (e: FileNotFoundException) { L.e(e) { "JsAssets file not found" }
L.e(e) { "JsAssets file not found" } JsInjector(JsActions.EMPTY.function)
JsInjector(JsActions.EMPTY.function)
}
} }
}
override fun inject(webView: WebView, prefs: Prefs) = override fun inject(webView: WebView, prefs: Prefs) =
injector(webView.context).inject(webView, prefs) injector(webView.context).inject(webView, prefs)
companion object { companion object {
// Ensures that all non themes and the selected theme are loaded // Ensures that all non themes and the selected theme are loaded
suspend fun load(context: Context) { suspend fun load(context: Context) {
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) { values().forEach { it.injector.invoke(context) } }
values().forEach { it.injector.invoke(context) }
}
}
} }
}
} }

View File

@ -21,125 +21,115 @@ import androidx.annotation.VisibleForTesting
import com.pitchedapps.frost.prefs.Prefs import com.pitchedapps.frost.prefs.Prefs
import com.pitchedapps.frost.utils.L import com.pitchedapps.frost.utils.L
import com.pitchedapps.frost.web.FrostWebViewClient import com.pitchedapps.frost.web.FrostWebViewClient
import org.apache.commons.text.StringEscapeUtils
import kotlin.random.Random import kotlin.random.Random
import org.apache.commons.text.StringEscapeUtils
class JsBuilder { class JsBuilder {
private val css = StringBuilder() private val css = StringBuilder()
private val js = StringBuilder() private val js = StringBuilder()
private var tag: String? = null private var tag: String? = null
fun css(css: String): JsBuilder { fun css(css: String): JsBuilder {
this.css.append(StringEscapeUtils.escapeEcmaScript(css)) this.css.append(StringEscapeUtils.escapeEcmaScript(css))
return this return this
} }
fun js(content: String): JsBuilder { fun js(content: String): JsBuilder {
this.js.append(content) this.js.append(content)
return this return this
} }
fun single(tag: String): JsBuilder { fun single(tag: String): JsBuilder {
this.tag = TagObfuscator.obfuscateTag(tag) this.tag = TagObfuscator.obfuscateTag(tag)
return this return this
} }
fun build() = JsInjector(toString()) fun build() = JsInjector(toString())
override fun toString(): String { override fun toString(): String {
val tag = this.tag val tag = this.tag
val builder = StringBuilder().apply { val builder =
append("!function(){") StringBuilder().apply {
if (css.isNotBlank()) { append("!function(){")
val cssMin = css.replace(Regex("\\s*\n\\s*"), "") if (css.isNotBlank()) {
append("var a=document.createElement('style');") val cssMin = css.replace(Regex("\\s*\n\\s*"), "")
append("a.innerHTML='$cssMin';") append("var a=document.createElement('style');")
if (tag != null) { append("a.innerHTML='$cssMin';")
append("a.id='$tag';") if (tag != null) {
} append("a.id='$tag';")
append("document.head.appendChild(a);") }
} append("document.head.appendChild(a);")
if (js.isNotBlank()) {
append(js)
}
} }
var content = builder.append("}()").toString() if (js.isNotBlank()) {
if (tag != null) { append(js)
content = singleInjector(tag, content)
} }
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("if (!window.hasOwnProperty(\"$tag\")) {")
append("console.log(\"Registering $tag\");") append("console.log(\"Registering $tag\");")
append("window.$tag = true;") append("window.$tag = true;")
append(content) append(content)
append("}") 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 { 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 */
* Toggle the injector (usually through Prefs fun maybe(enable: Boolean): InjectorContract = if (enable) this else JsActions.EMPTY
* 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) { fun WebView.jsInject(vararg injectors: InjectorContract, prefs: Prefs) {
injectors.asSequence().filter { it != JsActions.EMPTY }.forEach { injectors.asSequence().filter { it != JsActions.EMPTY }.forEach { it.inject(this, prefs) }
it.inject(this, prefs)
}
} }
fun FrostWebViewClient.jsInject(vararg injectors: InjectorContract, prefs: 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 { class JsInjector(val function: String) : InjectorContract {
override fun inject(webView: WebView, prefs: Prefs) = override fun inject(webView: WebView, prefs: Prefs) = webView.evaluateJavascript(function, null)
webView.evaluateJavascript(function, null)
} }
/** /** Helper object to obfuscate window tags for JS injection. */
* Helper object to obfuscate window tags for JS injection.
*/
@VisibleForTesting @VisibleForTesting
internal object TagObfuscator { internal object TagObfuscator {
fun obfuscateTag(tag: String): String { fun obfuscateTag(tag: String): String {
val rnd = Random(tag.hashCode() + salt) val rnd = Random(tag.hashCode() + salt)
val obfuscated = buildString { val obfuscated = buildString {
append(prefix) append(prefix)
append('_') append('_')
appendRandomChars(rnd, 16) appendRandomChars(rnd, 16)
}
L.v { "TagObfuscator: Obfuscating tag '$tag' to '$obfuscated'" }
return obfuscated
} }
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 { private val prefix: String by lazy {
val rnd = Random(System.currentTimeMillis()) val rnd = Random(System.currentTimeMillis())
buildString { appendRandomChars(rnd, 8) } buildString { appendRandomChars(rnd, 8) }
} }
private fun Appendable.appendRandomChars(random: Random, count: Int) { private fun Appendable.appendRandomChars(random: Random, count: Int) {
for (i in 1..count) { for (i in 1..count) {
append('a' + random.nextInt(26)) append('a' + random.nextInt(26))
}
} }
}
} }

View File

@ -35,157 +35,151 @@ import dagger.Module
import dagger.hilt.InstallIn import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent import dagger.hilt.components.SingletonComponent
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.BufferedReader import java.io.BufferedReader
import java.io.FileNotFoundException import java.io.FileNotFoundException
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
interface ThemeProvider { 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]] */
* Note that while this can be loaded from any thread, it is typically done through [preload]] fun injector(category: ThemeCategory): InjectorContract
*/
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]. * Provides [InjectorContract] for each [ThemeCategory]. Can be reloaded to take in changes from
* Can be reloaded to take in changes from [Prefs] * [Prefs]
*/ */
class ThemeProviderImpl @Inject internal constructor( class ThemeProviderImpl
@ApplicationContext private val context: Context, @Inject
private val prefs: Prefs internal constructor(@ApplicationContext private val context: Context, private val prefs: Prefs) :
) : ThemeProvider { ThemeProvider {
private var theme: Theme = Theme.values[prefs.theme] private var theme: Theme = Theme.values[prefs.theme]
set(value) { set(value) {
field = value field = value
prefs.theme = value.ordinal prefs.theme = value.ordinal
}
private val injectors: MutableMap<ThemeCategory, InjectorContract> = 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
}
} }
override fun setTheme(id: Int) { private val injectors: MutableMap<ThemeCategory, InjectorContract> = mutableMapOf()
if (theme.ordinal == id) return
theme = Theme.values[id]
reset()
}
override fun reset() { override val textColor: Int
injectors.clear() get() = theme.textColorGetter(prefs)
}
override suspend fun preload() { override val accentColor: Int
withContext(Dispatchers.IO) { get() = theme.accentColorGetter(prefs)
reset()
ThemeCategory.values().forEach { injector(it) } 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 @Module
@InstallIn(SingletonComponent::class) @InstallIn(SingletonComponent::class)
interface ThemeProviderModule { interface ThemeProviderModule {
@Binds @Binds @Singleton fun themeProvider(to: ThemeProviderImpl): ThemeProvider
@Singleton
fun themeProvider(to: ThemeProviderImpl): ThemeProvider
} }

View File

@ -24,52 +24,48 @@ import com.pitchedapps.frost.activities.IntroActivity
import com.pitchedapps.frost.databinding.IntroThemeBinding import com.pitchedapps.frost.databinding.IntroThemeBinding
import com.pitchedapps.frost.enums.Theme 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) { class IntroFragmentTheme : BaseIntroFragment(R.layout.intro_theme) {
private lateinit var binding: IntroThemeBinding private lateinit var binding: IntroThemeBinding
val themeList val themeList
get() = with(binding) { get() =
listOf(introThemeLight, introThemeDark, introThemeAmoled, introThemeGlass) with(binding) { listOf(introThemeLight, introThemeDark, introThemeAmoled, introThemeGlass) }
}
override fun viewArray(): Array<Array<out View>> = with(binding) { override fun viewArray(): Array<Array<out View>> =
arrayOf( with(binding) {
arrayOf(title), arrayOf(
arrayOf(introThemeLight, introThemeDark), arrayOf(title),
arrayOf(introThemeAmoled, introThemeGlass) arrayOf(introThemeLight, introThemeDark),
) arrayOf(introThemeAmoled, introThemeGlass)
)
} }
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState) super.onViewCreated(view, savedInstanceState)
binding = IntroThemeBinding.bind(view) binding = IntroThemeBinding.bind(view)
binding.init() binding.init()
} }
private fun IntroThemeBinding.init() { private fun IntroThemeBinding.init() {
introThemeLight.setThemeClick(Theme.LIGHT) introThemeLight.setThemeClick(Theme.LIGHT)
introThemeDark.setThemeClick(Theme.DARK) introThemeDark.setThemeClick(Theme.DARK)
introThemeAmoled.setThemeClick(Theme.AMOLED) introThemeAmoled.setThemeClick(Theme.AMOLED)
introThemeGlass.setThemeClick(Theme.GLASS) introThemeGlass.setThemeClick(Theme.GLASS)
val currentTheme = prefs.theme - 1 val currentTheme = prefs.theme - 1
if (currentTheme in 0..3) if (currentTheme in 0..3)
themeList.forEachIndexed { index, v -> themeList.forEachIndexed { index, v -> v.scaleXY = if (index == currentTheme) 1.6f else 0.8f }
v.scaleXY = if (index == currentTheme) 1.6f else 0.8f }
}
}
private fun View.setThemeClick(theme: Theme) { private fun View.setThemeClick(theme: Theme) {
setOnClickListener { v -> setOnClickListener { v ->
themeProvider.setTheme(theme.ordinal) themeProvider.setTheme(theme.ordinal)
(activity as IntroActivity).apply { (activity as IntroActivity).apply {
binding.ripple.ripple(themeProvider.bgColor, v.x + v.pivotX, v.y + v.pivotY) binding.ripple.ripple(themeProvider.bgColor, v.x + v.pivotX, v.y + v.pivotY)
theme() theme()
} }
themeList.forEach { it.animate().scaleXY(if (it == this) 1.6f else 0.8f).start() } themeList.forEach { it.animate().scaleXY(if (it == this) 1.6f else 0.8f).start() }
}
} }
}
} }

View File

@ -32,138 +32,142 @@ import com.pitchedapps.frost.R
import com.pitchedapps.frost.utils.launchTabCustomizerActivity import com.pitchedapps.frost.utils.launchTabCustomizerActivity
import kotlin.math.abs import kotlin.math.abs
/** /** Created by Allan Wang on 2017-07-28. */
* 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) {
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 imageDrawable: LayerDrawable by lazyResettableRegistered { image.drawable as LayerDrawable }
val phone: Drawable by lazyResettableRegistered { imageDrawable.findDrawableByLayerId(R.id.intro_phone) } val phone: Drawable by lazyResettableRegistered {
val screen: Drawable by lazyResettableRegistered { imageDrawable.findDrawableByLayerId(R.id.intro_phone_screen) } imageDrawable.findDrawableByLayerId(R.id.intro_phone)
val icon: ImageView by bindViewResettable(R.id.intro_button) }
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<Array<out View>> = arrayOf(arrayOf(title), arrayOf(desc)) override fun viewArray(): Array<Array<out View>> = arrayOf(arrayOf(title), arrayOf(desc))
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
title.setText(titleRes) title.setText(titleRes)
image.setImageResource(imageRes) image.setImageResource(imageRes)
desc.setText(descRes) desc.setText(descRes)
super.onViewCreated(view, savedInstanceState) 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() { fun firstImageFragmentTransition(offset: Float) {
super.themeFragmentImpl() if (offset < 0) image.alpha = 1 + offset
title.setTextColor(themeProvider.textColor) }
desc.setTextColor(themeProvider.textColor)
phone.tint(themeProvider.textColor)
screen.tint(themeProvider.bgColor)
}
fun themeImageComponent(color: Int, vararg id: Int) { fun lastImageFragmentTransition(offset: Float) {
id.forEach { imageDrawable.findDrawableByLayerId(it).tint(color) } if (offset > 0) image.alpha = 1 - offset
} }
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
}
} }
class IntroAccountFragment : BaseImageIntroFragment( class IntroAccountFragment :
BaseImageIntroFragment(
R.string.intro_multiple_accounts, R.string.intro_multiple_accounts,
R.drawable.intro_phone_nav, R.drawable.intro_phone_nav,
R.string.intro_multiple_accounts_desc R.string.intro_multiple_accounts_desc
) { ) {
override fun themeFragmentImpl() { override fun themeFragmentImpl() {
super.themeFragmentImpl() super.themeFragmentImpl()
themeImageComponent(themeProvider.iconColor, R.id.intro_phone_avatar_1, R.id.intro_phone_avatar_2) themeImageComponent(
themeImageComponent(themeProvider.bgColor.colorToForeground(), R.id.intro_phone_nav) themeProvider.iconColor,
themeImageComponent(themeProvider.headerColor, R.id.intro_phone_header) 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) { override fun onPageScrolledImpl(positionOffset: Float) {
super.onPageScrolledImpl(positionOffset) super.onPageScrolledImpl(positionOffset)
firstImageFragmentTransition(positionOffset) firstImageFragmentTransition(positionOffset)
} }
} }
class IntroTabTouchFragment : BaseImageIntroFragment( class IntroTabTouchFragment :
R.string.intro_easy_navigation, R.drawable.intro_phone_tab, R.string.intro_easy_navigation_desc BaseImageIntroFragment(
) { R.string.intro_easy_navigation,
R.drawable.intro_phone_tab,
R.string.intro_easy_navigation_desc
) {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState) super.onViewCreated(view, savedInstanceState)
icon.visible().setIcon(GoogleMaterial.Icon.gmd_edit, 24) icon.visible().setIcon(GoogleMaterial.Icon.gmd_edit, 24)
icon.setOnClickListener { icon.setOnClickListener { activity?.launchTabCustomizerActivity() }
activity?.launchTabCustomizerActivity() }
}
}
override fun themeFragmentImpl() { override fun themeFragmentImpl() {
super.themeFragmentImpl() super.themeFragmentImpl()
themeImageComponent( themeImageComponent(
themeProvider.iconColor, themeProvider.iconColor,
R.id.intro_phone_icon_1, R.id.intro_phone_icon_1,
R.id.intro_phone_icon_2, R.id.intro_phone_icon_2,
R.id.intro_phone_icon_3, R.id.intro_phone_icon_3,
R.id.intro_phone_icon_4 R.id.intro_phone_icon_4
) )
themeImageComponent(themeProvider.headerColor, R.id.intro_phone_tab) themeImageComponent(themeProvider.headerColor, R.id.intro_phone_tab)
themeImageComponent(themeProvider.textColor.withAlpha(80), R.id.intro_phone_icon_ripple) themeImageComponent(themeProvider.textColor.withAlpha(80), R.id.intro_phone_icon_ripple)
} }
} }
class IntroTabContextFragment : BaseImageIntroFragment( class IntroTabContextFragment :
BaseImageIntroFragment(
R.string.intro_context_aware, R.string.intro_context_aware,
R.drawable.intro_phone_long_press, R.drawable.intro_phone_long_press,
R.string.intro_context_aware_desc R.string.intro_context_aware_desc
) { ) {
override fun themeFragmentImpl() { override fun themeFragmentImpl() {
super.themeFragmentImpl() super.themeFragmentImpl()
themeImageComponent(themeProvider.headerColor, R.id.intro_phone_toolbar) themeImageComponent(themeProvider.headerColor, R.id.intro_phone_toolbar)
themeImageComponent(themeProvider.bgColor.colorToForeground(0.1f), R.id.intro_phone_image) themeImageComponent(themeProvider.bgColor.colorToForeground(0.1f), R.id.intro_phone_image)
themeImageComponent( themeImageComponent(
themeProvider.bgColor.colorToForeground(0.2f), themeProvider.bgColor.colorToForeground(0.2f),
R.id.intro_phone_like, R.id.intro_phone_like,
R.id.intro_phone_share R.id.intro_phone_share
) )
themeImageComponent(themeProvider.bgColor.colorToForeground(0.3f), R.id.intro_phone_comment) themeImageComponent(themeProvider.bgColor.colorToForeground(0.3f), R.id.intro_phone_comment)
themeImageComponent( themeImageComponent(
themeProvider.bgColor.colorToForeground(0.1f), themeProvider.bgColor.colorToForeground(0.1f),
R.id.intro_phone_card_1, R.id.intro_phone_card_1,
R.id.intro_phone_card_2 R.id.intro_phone_card_2
) )
themeImageComponent( themeImageComponent(
themeProvider.textColor, themeProvider.textColor,
R.id.intro_phone_image_indicator, R.id.intro_phone_image_indicator,
R.id.intro_phone_comment_indicator, R.id.intro_phone_comment_indicator,
R.id.intro_phone_card_indicator R.id.intro_phone_card_indicator
) )
} }
override fun onPageScrolledImpl(positionOffset: Float) { override fun onPageScrolledImpl(positionOffset: Float) {
super.onPageScrolledImpl(positionOffset) super.onPageScrolledImpl(positionOffset)
lastImageFragmentTransition(positionOffset) lastImageFragmentTransition(positionOffset)
} }
} }

View File

@ -45,122 +45,118 @@ import kotlin.math.abs
* Contains the base, start, and end fragments * Contains the base, start, and end fragments
*/ */
/** /** The core intro fragment for all other fragments */
* The core intro fragment for all other fragments
*/
@AndroidEntryPoint @AndroidEntryPoint
abstract class BaseIntroFragment(val layoutRes: Int) : Fragment() { abstract class BaseIntroFragment(val layoutRes: Int) : Fragment() {
@Inject @Inject protected lateinit var prefs: Prefs
protected lateinit var prefs: Prefs
@Inject @Inject protected lateinit var themeProvider: ThemeProvider
protected lateinit var themeProvider: ThemeProvider
val screenWidth val screenWidth
get() = resources.displayMetrics.widthPixels get() = resources.displayMetrics.widthPixels
val lazyRegistry = LazyResettableRegistry() val lazyRegistry = LazyResettableRegistry()
protected fun translate(offset: Float, views: Array<Array<out View>>) { protected fun translate(offset: Float, views: Array<Array<out View>>) {
val maxTranslation = offset * screenWidth val maxTranslation = offset * screenWidth
val increment = maxTranslation / views.size val increment = maxTranslation / views.size
views.forEachIndexed { i, group -> views.forEachIndexed { i, group ->
group.forEach { group.forEach {
it.translationX = it.translationX = if (offset > 0) -maxTranslation + i * increment else -(i + 1) * increment
if (offset > 0) -maxTranslation + i * increment else -(i + 1) * increment it.alpha = 1 - abs(offset)
it.alpha = 1 - abs(offset) }
}
}
} }
}
fun <T : Any> lazyResettableRegistered(initializer: () -> T) = lazyRegistry.lazy(initializer) fun <T : Any> lazyResettableRegistered(initializer: () -> T) = lazyRegistry.lazy(initializer)
/* /*
* Note that these ids aren't actually inside all layouts * Note that these ids aren't actually inside all layouts
* However, they are in most of them, so they are added here * However, they are in most of them, so they are added here
* for convenience * for convenience
*/ */
protected val title: TextView by bindViewResettable(R.id.intro_title) protected val title: TextView by bindViewResettable(R.id.intro_title)
protected val image: ImageView by bindViewResettable(R.id.intro_image) protected val image: ImageView by bindViewResettable(R.id.intro_image)
protected val desc: TextView by bindViewResettable(R.id.intro_desc) protected val desc: TextView by bindViewResettable(R.id.intro_desc)
protected fun defaultViewArray(): Array<Array<out View>> = protected fun defaultViewArray(): Array<Array<out View>> =
arrayOf(arrayOf(title), arrayOf(image), arrayOf(desc)) arrayOf(arrayOf(title), arrayOf(image), arrayOf(desc))
override fun onCreateView( override fun onCreateView(
inflater: LayoutInflater, inflater: LayoutInflater,
container: ViewGroup?, container: ViewGroup?,
savedInstanceState: Bundle? savedInstanceState: Bundle?
): View? { ): View? {
return inflater.inflate(layoutRes, container, false) 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?) { protected val viewArray: Array<Array<out View>> by lazyResettableRegistered { viewArray() }
super.onViewCreated(view, savedInstanceState)
themeFragment()
}
override fun onDestroyView() { protected abstract fun viewArray(): Array<Array<out View>>
Kotterknife.reset(this)
lazyRegistry.invalidateAll()
super.onDestroyView()
}
fun themeFragment() { fun onPageScrolled(positionOffset: Float) {
if (view != null) themeFragmentImpl() if (view != null) onPageScrolledImpl(positionOffset)
} }
protected open fun themeFragmentImpl() { protected open fun onPageScrolledImpl(positionOffset: Float) {
(view as? ViewGroup)?.children?.forEach { (it as? TextView)?.setTextColor(themeProvider.textColor) } translate(positionOffset, viewArray)
} }
protected val viewArray: Array<Array<out View>> by lazyResettableRegistered { viewArray() } fun onPageSelected() {
if (view != null) onPageSelectedImpl()
}
protected abstract fun viewArray(): Array<Array<out View>> protected open fun onPageSelectedImpl() {}
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() {
}
} }
class IntroFragmentWelcome : BaseIntroFragment(R.layout.intro_welcome) { class IntroFragmentWelcome : BaseIntroFragment(R.layout.intro_welcome) {
override fun viewArray(): Array<Array<out View>> = defaultViewArray() override fun viewArray(): Array<Array<out View>> = defaultViewArray()
override fun themeFragmentImpl() { override fun themeFragmentImpl() {
super.themeFragmentImpl() super.themeFragmentImpl()
image.imageTintList = ColorStateList.valueOf(themeProvider.textColor) image.imageTintList = ColorStateList.valueOf(themeProvider.textColor)
} }
} }
class IntroFragmentEnd : BaseIntroFragment(R.layout.intro_end) { 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<Array<out View>> = defaultViewArray() override fun viewArray(): Array<Array<out View>> = defaultViewArray()
override fun themeFragmentImpl() { override fun themeFragmentImpl() {
super.themeFragmentImpl() super.themeFragmentImpl()
image.imageTintList = ColorStateList.valueOf(themeProvider.textColor) image.imageTintList = ColorStateList.valueOf(themeProvider.textColor)
} }
@SuppressLint("ClickableViewAccessibility") @SuppressLint("ClickableViewAccessibility")
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState) super.onViewCreated(view, savedInstanceState)
container.setOnSingleTapListener { _, event -> container.setOnSingleTapListener { _, event ->
(activity as IntroActivity).finish(event.x, event.y) (activity as IntroActivity).finish(event.x, event.y)
}
} }
}
} }

View File

@ -31,109 +31,109 @@ import javax.inject.Inject
*/ */
@Deprecated(level = DeprecationLevel.WARNING, message = "Use pref segments") @Deprecated(level = DeprecationLevel.WARNING, message = "Use pref segments")
class OldPrefs @Inject internal constructor(factory: KPrefFactory) : 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<String> by kpref("notification_keywords", mutableSetOf()) var notificationKeywords: Set<String> 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. * Despite the naming, this toggle currently only enables debug logging. Verbose is never logged
* Verbose is never logged in release builds. * in release builds.
*/ */
var verboseLogging: Boolean by kpref("verbose_logging", false) 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)
} }

View File

@ -41,92 +41,76 @@ import javax.inject.Inject
import javax.inject.Singleton 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 { interface PrefsBase {
fun reset() fun reset()
fun deleteKeys(vararg keys: String) fun deleteKeys(vararg keys: String)
} }
interface Prefs : interface Prefs :
BehaviourPrefs, BehaviourPrefs, CorePrefs, FeedPrefs, NotifPrefs, ThemePrefs, ShowcasePrefs, PrefsBase
CorePrefs,
FeedPrefs,
NotifPrefs,
ThemePrefs,
ShowcasePrefs,
PrefsBase
class PrefsImpl @Inject internal constructor( class PrefsImpl
private val behaviourPrefs: BehaviourPrefs, @Inject
private val corePrefs: CorePrefs, internal constructor(
private val feedPrefs: FeedPrefs, private val behaviourPrefs: BehaviourPrefs,
private val notifPrefs: NotifPrefs, private val corePrefs: CorePrefs,
private val themePrefs: ThemePrefs, private val feedPrefs: FeedPrefs,
private val showcasePrefs: ShowcasePrefs private val notifPrefs: NotifPrefs,
) : Prefs, private val themePrefs: ThemePrefs,
BehaviourPrefs by behaviourPrefs, private val showcasePrefs: ShowcasePrefs
CorePrefs by corePrefs, ) :
FeedPrefs by feedPrefs, Prefs,
NotifPrefs by notifPrefs, BehaviourPrefs by behaviourPrefs,
ThemePrefs by themePrefs, CorePrefs by corePrefs,
ShowcasePrefs by showcasePrefs { FeedPrefs by feedPrefs,
NotifPrefs by notifPrefs,
ThemePrefs by themePrefs,
ShowcasePrefs by showcasePrefs {
override fun reset() { override fun reset() {
behaviourPrefs.reset() behaviourPrefs.reset()
corePrefs.reset() corePrefs.reset()
feedPrefs.reset() feedPrefs.reset()
notifPrefs.reset() notifPrefs.reset()
themePrefs.reset() themePrefs.reset()
showcasePrefs.reset() showcasePrefs.reset()
} }
override fun deleteKeys(vararg keys: String) { override fun deleteKeys(vararg keys: String) {
behaviourPrefs.deleteKeys() behaviourPrefs.deleteKeys()
corePrefs.deleteKeys() corePrefs.deleteKeys()
feedPrefs.deleteKeys() feedPrefs.deleteKeys()
notifPrefs.deleteKeys() notifPrefs.deleteKeys()
themePrefs.deleteKeys() themePrefs.deleteKeys()
showcasePrefs.deleteKeys() showcasePrefs.deleteKeys()
} }
} }
@Module @Module
@InstallIn(SingletonComponent::class) @InstallIn(SingletonComponent::class)
interface PrefModule { interface PrefModule {
@Binds @Binds @Singleton fun behaviour(to: BehaviourPrefsImpl): BehaviourPrefs
@Singleton
fun behaviour(to: BehaviourPrefsImpl): BehaviourPrefs
@Binds @Binds @Singleton fun core(to: CorePrefsImpl): CorePrefs
@Singleton
fun core(to: CorePrefsImpl): CorePrefs
@Binds @Binds @Singleton fun feed(to: FeedPrefsImpl): FeedPrefs
@Singleton
fun feed(to: FeedPrefsImpl): FeedPrefs
@Binds @Binds @Singleton fun notif(to: NotifPrefsImpl): NotifPrefs
@Singleton
fun notif(to: NotifPrefsImpl): NotifPrefs
@Binds @Binds @Singleton fun theme(to: ThemePrefsImpl): ThemePrefs
@Singleton
fun theme(to: ThemePrefsImpl): ThemePrefs
@Binds @Binds @Singleton fun showcase(to: ShowcasePrefsImpl): ShowcasePrefs
@Singleton
fun showcase(to: ShowcasePrefsImpl): ShowcasePrefs
@Binds @Binds @Singleton fun prefs(to: PrefsImpl): Prefs
@Singleton
fun prefs(to: PrefsImpl): Prefs
} }
@Module @Module
@InstallIn(SingletonComponent::class) @InstallIn(SingletonComponent::class)
object PrefFactoryModule { object PrefFactoryModule {
@Provides @Provides
@Singleton @Singleton
fun factory(@ApplicationContext context: Context): KPrefFactory = KPrefFactoryAndroid(context) fun factory(@ApplicationContext context: Context): KPrefFactory = KPrefFactoryAndroid(context)
} }

View File

@ -24,87 +24,67 @@ import com.pitchedapps.frost.prefs.PrefsBase
import javax.inject.Inject import javax.inject.Inject
interface BehaviourPrefs : PrefsBase { 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( class BehaviourPrefsImpl
factory: KPrefFactory, @Inject
oldPrefs: OldPrefs, internal constructor(
factory: KPrefFactory,
oldPrefs: OldPrefs,
) : KPref("${BuildConfig.APPLICATION_ID}.prefs.behaviour", factory), BehaviourPrefs { ) : KPref("${BuildConfig.APPLICATION_ID}.prefs.behaviour", factory), BehaviourPrefs {
override var biometricsEnabled: Boolean by kpref( override var biometricsEnabled: Boolean by
"biometrics_enabled", kpref("biometrics_enabled", oldPrefs.biometricsEnabled /* false */)
oldPrefs.biometricsEnabled /* false */
)
override var overlayEnabled: Boolean by kpref( override var overlayEnabled: Boolean by
"overlay_enabled", kpref("overlay_enabled", oldPrefs.overlayEnabled /* true */)
oldPrefs.overlayEnabled /* true */
)
override var overlayFullScreenSwipe: Boolean by kpref( override var overlayFullScreenSwipe: Boolean by
"overlay_full_screen_swipe", kpref("overlay_full_screen_swipe", oldPrefs.overlayFullScreenSwipe /* true */)
oldPrefs.overlayFullScreenSwipe /* true */
)
override var viewpagerSwipe: Boolean by kpref( override var viewpagerSwipe: Boolean by
"viewpager_swipe", kpref("viewpager_swipe", oldPrefs.viewpagerSwipe /* true */)
oldPrefs.viewpagerSwipe /* true */
)
override var loadMediaOnMeteredNetwork: Boolean by kpref( override var loadMediaOnMeteredNetwork: Boolean by
"media_on_metered_network", kpref("media_on_metered_network", oldPrefs.loadMediaOnMeteredNetwork /* true */)
oldPrefs.loadMediaOnMeteredNetwork /* true */
)
override var debugSettings: Boolean by kpref( override var debugSettings: Boolean by kpref("debug_settings", oldPrefs.debugSettings /* false */)
"debug_settings",
oldPrefs.debugSettings /* false */
)
override var linksInDefaultApp: Boolean by kpref( override var linksInDefaultApp: Boolean by
"link_in_default_app", kpref("link_in_default_app", oldPrefs.linksInDefaultApp /* false */)
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( override var autoRefreshFeed: Boolean by
"auto_refresh_feed", kpref("auto_refresh_feed", oldPrefs.autoRefreshFeed /* false */)
oldPrefs.autoRefreshFeed /* false */
)
override var showCreateFab: Boolean by kpref( override var showCreateFab: Boolean by kpref("show_create_fab", oldPrefs.showCreateFab /* true */)
"show_create_fab",
oldPrefs.showCreateFab /* true */
)
override var fullSizeImage: Boolean by kpref( override var fullSizeImage: Boolean by
"full_size_image", kpref("full_size_image", oldPrefs.fullSizeImage /* false */)
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)
} }

View File

@ -24,78 +24,71 @@ import com.pitchedapps.frost.prefs.PrefsBase
import javax.inject.Inject import javax.inject.Inject
interface CorePrefs : PrefsBase { 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. * Despite the naming, this toggle currently only enables debug logging. Verbose is never logged
* Verbose is never logged in release builds. * in release builds.
*/ */
var verboseLogging: Boolean 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( class CorePrefsImpl
factory: KPrefFactory, @Inject
oldPrefs: OldPrefs, internal constructor(
factory: KPrefFactory,
oldPrefs: OldPrefs,
) : KPref("${BuildConfig.APPLICATION_ID}.prefs.core", factory), CorePrefs { ) : 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 override val frostId: String
get() = "$installDate-$identifier" 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( override var prevVersionCode: Int by kpref("prev_version_code", oldPrefs.prevVersionCode /* -1 */)
"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( override var verboseLogging: Boolean by
"verbose_logging", kpref("verbose_logging", oldPrefs.verboseLogging /* false */)
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( override var exitConfirmation: Boolean by
"exit_confirmation", kpref("exit_confirmation", oldPrefs.exitConfirmation /* true */)
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( override var messageScrollToBottom: Boolean by
"message_scroll_to_bottom", kpref("message_scroll_to_bottom", oldPrefs.messageScrollToBottom /* false */)
oldPrefs.messageScrollToBottom /* false */
)
} }

View File

@ -25,79 +25,62 @@ import com.pitchedapps.frost.prefs.PrefsBase
import javax.inject.Inject import javax.inject.Inject
interface FeedPrefs : PrefsBase { 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( class FeedPrefsImpl @Inject internal constructor(factory: KPrefFactory, oldPrefs: OldPrefs) :
factory: KPrefFactory, KPref("${BuildConfig.APPLICATION_ID}.prefs.feed", factory), FeedPrefs {
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( override var feedSort: Int by kpref("feed_sort", oldPrefs.feedSort /* FeedSort.DEFAULT.ordinal */)
"feed_sort",
oldPrefs.feedSort /* FeedSort.DEFAULT.ordinal */
)
override var aggressiveRecents: Boolean by kpref( override var aggressiveRecents: Boolean by
"aggressive_recents", kpref("aggressive_recents", oldPrefs.aggressiveRecents /* false */)
oldPrefs.aggressiveRecents /* false */
)
override var showComposer: Boolean by kpref( override var showComposer: Boolean by
"status_composer_feed", kpref("status_composer_feed", oldPrefs.showComposer /* true */)
oldPrefs.showComposer /* true */
)
override var showSuggestedFriends: Boolean by kpref( override var showSuggestedFriends: Boolean by
"suggested_friends_feed", kpref("suggested_friends_feed", oldPrefs.showSuggestedFriends /* true */)
oldPrefs.showSuggestedFriends /* true */
)
override var showSuggestedGroups: Boolean by kpref( override var showSuggestedGroups: Boolean by
"suggested_groups_feed", kpref("suggested_groups_feed", oldPrefs.showSuggestedGroups /* true */)
oldPrefs.showSuggestedGroups /* true */
)
override var showFacebookAds: Boolean by kpref( override var showFacebookAds: Boolean by
"facebook_ads", kpref("facebook_ads", oldPrefs.showFacebookAds /* false */)
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( override var mainActivityLayoutType: Int by
"main_activity_layout_type", kpref("main_activity_layout_type", oldPrefs.mainActivityLayoutType /* 0 */)
oldPrefs.mainActivityLayoutType /* 0 */
)
override val mainActivityLayout: MainActivityLayout override val mainActivityLayout: MainActivityLayout
get() = MainActivityLayout(mainActivityLayoutType) 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)
} }

View File

@ -24,86 +24,66 @@ import com.pitchedapps.frost.prefs.PrefsBase
import javax.inject.Inject import javax.inject.Inject
interface NotifPrefs : PrefsBase { interface NotifPrefs : PrefsBase {
var notificationKeywords: Set<String> var notificationKeywords: Set<String>
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( class NotifPrefsImpl
factory: KPrefFactory, @Inject
oldPrefs: OldPrefs, internal constructor(
factory: KPrefFactory,
oldPrefs: OldPrefs,
) : KPref("${BuildConfig.APPLICATION_ID}.prefs.notif", factory), NotifPrefs { ) : KPref("${BuildConfig.APPLICATION_ID}.prefs.notif", factory), NotifPrefs {
override var notificationKeywords: Set<String> by kpref( override var notificationKeywords: Set<String> by
"notification_keywords", kpref("notification_keywords", oldPrefs.notificationKeywords /* mutableSetOf() */)
oldPrefs.notificationKeywords /* mutableSetOf() */
)
override var notificationsGeneral: Boolean by kpref( override var notificationsGeneral: Boolean by
"notification_general", kpref("notification_general", oldPrefs.notificationsGeneral /* true */)
oldPrefs.notificationsGeneral /* true */
)
override var notificationAllAccounts: Boolean by kpref( override var notificationAllAccounts: Boolean by
"notification_all_accounts", kpref("notification_all_accounts", oldPrefs.notificationAllAccounts /* true */)
oldPrefs.notificationAllAccounts /* true */
)
override var notificationsInstantMessages: Boolean by kpref( override var notificationsInstantMessages: Boolean by
"notification_im", kpref("notification_im", oldPrefs.notificationsInstantMessages /* true */)
oldPrefs.notificationsInstantMessages /* true */
)
override var notificationsImAllAccounts: Boolean by kpref( override var notificationsImAllAccounts: Boolean by
"notification_im_all_accounts", kpref("notification_im_all_accounts", oldPrefs.notificationsImAllAccounts /* false */)
oldPrefs.notificationsImAllAccounts /* false */
)
override var notificationVibrate: Boolean by kpref( override var notificationVibrate: Boolean by
"notification_vibrate", kpref("notification_vibrate", oldPrefs.notificationVibrate /* true */)
oldPrefs.notificationVibrate /* true */
)
override var notificationSound: Boolean by kpref( override var notificationSound: Boolean by
"notification_sound", kpref("notification_sound", oldPrefs.notificationSound /* true */)
oldPrefs.notificationSound /* true */
)
override var notificationRingtone: String by kpref( override var notificationRingtone: String by
"notification_ringtone", kpref("notification_ringtone", oldPrefs.notificationRingtone /* "" */)
oldPrefs.notificationRingtone /* "" */
)
override var messageRingtone: String by kpref( override var messageRingtone: String by
"message_ringtone", kpref("message_ringtone", oldPrefs.messageRingtone /* "" */)
oldPrefs.messageRingtone /* "" */
)
override var notificationLights: Boolean by kpref( override var notificationLights: Boolean by
"notification_lights", kpref("notification_lights", oldPrefs.notificationLights /* true */)
oldPrefs.notificationLights /* true */
)
override var notificationFreq: Long by kpref( override var notificationFreq: Long by
"notification_freq", kpref("notification_freq", oldPrefs.notificationFreq /* 15L */)
oldPrefs.notificationFreq /* 15L */
)
} }

View File

@ -23,12 +23,10 @@ import com.pitchedapps.frost.prefs.PrefsBase
import javax.inject.Inject import javax.inject.Inject
interface ShowcasePrefs : PrefsBase { interface ShowcasePrefs : PrefsBase {
/** /** Check if this is the first time launching the web overlay; show snackbar if true */
* Check if this is the first time launching the web overlay; show snackbar if true val firstWebOverlay: Boolean
*/
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 * Showcase prefs that offer one time helpers to guide new users
*/ */
class ShowcasePrefsImpl @Inject internal constructor( class ShowcasePrefsImpl @Inject internal constructor(factory: KPrefFactory) :
factory: KPrefFactory KPref("${BuildConfig.APPLICATION_ID}.showcase", factory), ShowcasePrefs {
) : 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")
} }

View File

@ -24,56 +24,45 @@ import com.pitchedapps.frost.prefs.PrefsBase
import javax.inject.Inject import javax.inject.Inject
interface ThemePrefs : PrefsBase { 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( class ThemePrefsImpl
factory: KPrefFactory, @Inject
oldPrefs: OldPrefs, internal constructor(
factory: KPrefFactory,
oldPrefs: OldPrefs,
) : KPref("${BuildConfig.APPLICATION_ID}.prefs.theme", factory), ThemePrefs { ) : KPref("${BuildConfig.APPLICATION_ID}.prefs.theme", factory), ThemePrefs {
/** /** Note that this is purely for the pref storage. Updating themes should use ThemeProvider */
* Note that this is purely for the pref storage. Updating themes should use override var theme: Int by kpref("theme", oldPrefs.theme /* 0 */)
* ThemeProvider
*/
override var theme: Int by kpref("theme", oldPrefs.theme /* 0 */)
override var customTextColor: Int by kpref( override var customTextColor: Int by
"color_text", kpref("color_text", oldPrefs.customTextColor /* 0xffeceff1.toInt() */)
oldPrefs.customTextColor /* 0xffeceff1.toInt() */
)
override var customAccentColor: Int by kpref( override var customAccentColor: Int by
"color_accent", kpref("color_accent", oldPrefs.customAccentColor /* 0xff0288d1.toInt() */)
oldPrefs.customAccentColor /* 0xff0288d1.toInt() */
)
override var customBackgroundColor: Int by kpref( override var customBackgroundColor: Int by
"color_bg", kpref("color_bg", oldPrefs.customBackgroundColor /* 0xff212121.toInt() */)
oldPrefs.customBackgroundColor /* 0xff212121.toInt() */
)
override var customHeaderColor: Int by kpref( override var customHeaderColor: Int by
"color_header", kpref("color_header", oldPrefs.customHeaderColor /* 0xff01579b.toInt() */)
oldPrefs.customHeaderColor /* 0xff01579b.toInt() */
)
override var customIconColor: Int by kpref( override var customIconColor: Int by
"color_icons", kpref("color_icons", oldPrefs.customIconColor /* 0xffeceff1.toInt() */)
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 */)
} }

View File

@ -20,32 +20,30 @@ import android.app.job.JobParameters
import android.app.job.JobService import android.app.job.JobService
import androidx.annotation.CallSuper import androidx.annotation.CallSuper
import ca.allanwang.kau.utils.ContextHelper import ca.allanwang.kau.utils.ContextHelper
import kotlin.coroutines.CoroutineContext
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlin.coroutines.CoroutineContext
abstract class BaseJobService : JobService(), CoroutineScope { abstract class BaseJobService : JobService(), CoroutineScope {
private lateinit var job: Job private lateinit var job: Job
override val coroutineContext: CoroutineContext override val coroutineContext: CoroutineContext
get() = ContextHelper.dispatcher + job 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 */
* Note that if a job plans on running asynchronously, it should return true @CallSuper
*/ override fun onStartJob(params: JobParameters?): Boolean {
@CallSuper job = Job()
override fun onStartJob(params: JobParameters?): Boolean { return false
job = Job() }
return false
}
@CallSuper @CallSuper
override fun onStopJob(params: JobParameters?): Boolean { override fun onStopJob(params: JobParameters?): Boolean {
job.cancel() job.cancel()
return false return false
} }
} }
/* /*

View File

@ -60,283 +60,271 @@ import kotlin.math.abs
private val _40_DP = 40.dpToPx private val _40_DP = 40.dpToPx
private val pendingIntentFlagUpdateCurrent: Int private val pendingIntentFlagUpdateCurrent: Int
get() = PendingIntent.FLAG_UPDATE_CURRENT or get() =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) PendingIntent.FLAG_IMMUTABLE else 0 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( enum class NotificationType(
val channelId: String, val channelId: String,
private val overlayContext: OverlayContext, private val overlayContext: OverlayContext,
private val fbItem: FbItem, private val fbItem: FbItem,
private val parser: FrostParser<ParseNotification>, private val parser: FrostParser<ParseNotification>,
private val ringtoneProvider: (Prefs) -> String 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( private val groupPrefix = "frost_${name.toLowerCase(Locale.CANADA)}"
NOTIF_CHANNEL_GENERAL,
OverlayContext.NOTIFICATION,
FbItem.NOTIFICATIONS,
NotifParser,
{ it.notificationRingtone }
),
MESSAGE( /** Optional binder to return the request bundle builder */
NOTIF_CHANNEL_MESSAGES, internal open fun bindRequest(
OverlayContext.MESSAGE, content: NotificationContent,
FbItem.MESSAGES, cookie: String
MessageParser, ): (BaseBundle.() -> Unit)? = null
{ it.messageRingtone }
);
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 * Get unread data from designated parser Display notifications for those after old epoch Save new
*/ * epoch
internal open fun bindRequest( *
content: NotificationContent, * Returns the number of notifications generated, or -1 if an error occurred
cookie: String */
): (BaseBundle.() -> Unit)? = null suspend fun fetch(
context: Context,
private fun bindRequest(intent: Intent, content: NotificationContent) { data: CookieEntity,
val cookie = content.data.cookie ?: return prefs: Prefs,
val binder = bindRequest(content, cookie) ?: return notifDao: NotificationDao
val bundle = Bundle() ): Int {
bundle.binder() val response =
intent.putExtras(bundle) 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 */
* Get unread data from designated parser fun validText(text: String?): Boolean {
* Display notifications for those after old epoch val t = text ?: return true
* Save new epoch return prefs.notificationKeywords.none { t.contains(it, true) }
*
* 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
} }
fun debugNotification(context: Context, data: CookieEntity) { val notifContents =
val content = NotificationContent( response.data.getUnreadNotifications(data).filter { notif ->
data, validText(notif.title) && validText(notif.text)
System.currentTimeMillis(), }
"https://github.com/AllanWang/Frost-for-Facebook", if (notifContents.isEmpty()) return 0
"Debug Notif",
"Test 123", val userId = data.id
System.currentTimeMillis() / 1000, val prevLatestEpoch = notifDao.latestEpoch(userId, channelId)
"https://www.iconexperience.com/_img/v_collection_png/256x256/shadow/dog.png", L.v { "Notif $name prev epoch $prevLatestEpoch" }
false
) if (!notifDao.saveNotifications(channelId, notifContents)) {
createNotification(context, content).notify(context) L.d { "Skip notifs for $name as saving failed" }
return -1
} }
/** if (prevLatestEpoch == -1L && !BuildConfig.DEBUG) {
* Attach content related data to an intent L.d { "Skipping first notification fetch" }
*/ return 0 // do not notify the first time
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
} }
/** val newNotifContents = notifContents.filter { it.timestamp > prevLatestEpoch }
* Create a generic content for the provided type and user id.
* No content related data is added if (newNotifContents.isEmpty()) {
*/ L.d { "No new notifs found for $name" }
fun createCommonIntent(context: Context, userId: Long): Intent { return 0
val intent = Intent(context, FrostWebActivity::class.java)
intent.putExtra(ARG_USER_ID, userId)
overlayContext.put(intent)
return intent
} }
/** L.d { "${newNotifContents.size} new notifs found for $name" }
* 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) val notifs = newNotifContents.map { createNotification(context, it) }
L.v { "Notif load $content" }
if (profileUrl != null) { frostEvent("Notifications", "Type" to name, "Count" to notifs.size)
try { if (notifs.size > 1) summaryNotification(context, userId, notifs.size).notify(context)
val profileImg = GlideApp.with(context) val ringtone = ringtoneProvider(prefs)
.asBitmap() notifs.forEachIndexed { i, notif ->
.load(profileUrl) // Ring at most twice
.transform(FrostGlide.circleCrop) notif.withAlert(context, i < 2, ringtone, prefs).notify(context)
.submit(_40_DP, _40_DP) }
.get() return notifs.size
notifBuilder.setLargeIcon(profileImg) }
} catch (e: Exception) {
L.e { "Failed to get image $profileUrl" }
}
}
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" }
} }
}
/** 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)
} }
/**
* 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( data class NotificationContent(
// TODO replace data with userId? // TODO replace data with userId?
val data: CookieEntity, val data: CookieEntity,
val id: Long, val id: Long,
val href: String, val href: String,
val title: String? = null, // defaults to frost title val title: String? = null, // defaults to frost title
val text: String, val text: String,
val timestamp: Long, val timestamp: Long,
val profileUrl: String?, val profileUrl: String?,
val unread: Boolean val unread: Boolean
) { ) {
val notifId = abs(id.toInt()) val notifId = abs(id.toInt())
} }
/** /**
* Wrapper for a complete notification builder and identifier * Wrapper for a complete notification builder and identifier which can be immediately notified when
* which can be immediately notified when given a [Context] * given a [Context]
*/ */
data class FrostNotification( data class FrostNotification(
private val tag: String, private val tag: String,
private val id: Int, private val id: Int,
val notif: NotificationCompat.Builder val notif: NotificationCompat.Builder
) { ) {
fun withAlert( fun withAlert(
context: Context, context: Context,
enable: Boolean, enable: Boolean,
ringtone: String, ringtone: String,
prefs: Prefs prefs: Prefs
): FrostNotification { ): FrostNotification {
notif.setFrostAlert(context, enable, ringtone, prefs) notif.setFrostAlert(context, enable, ringtone, prefs)
return this return this
} }
fun notify(context: Context) = fun notify(context: Context) =
NotificationManagerCompat.from(context).notify(tag, id, notif.build()) NotificationManagerCompat.from(context).notify(tag, id, notif.build())
} }
fun Context.scheduleNotificationsFromPrefs(prefs: Prefs): Boolean { fun Context.scheduleNotificationsFromPrefs(prefs: Prefs): Boolean {
val shouldSchedule = prefs.hasNotifications val shouldSchedule = prefs.hasNotifications
return if (shouldSchedule) scheduleNotifications(prefs.notificationFreq) return if (shouldSchedule) scheduleNotifications(prefs.notificationFreq)
else scheduleNotifications(-1) else scheduleNotifications(-1)
} }
fun Context.scheduleNotifications(minutes: Long): Boolean = fun Context.scheduleNotifications(minutes: Long): Boolean =
scheduleJob<NotificationService>(NOTIFICATION_PERIODIC_JOB, minutes) scheduleJob<NotificationService>(NOTIFICATION_PERIODIC_JOB, minutes)
fun Context.fetchNotifications(): Boolean = fun Context.fetchNotifications(): Boolean = fetchJob<NotificationService>(NOTIFICATION_JOB_NOW)
fetchJob<NotificationService>(NOTIFICATION_JOB_NOW)

View File

@ -30,122 +30,116 @@ import com.pitchedapps.frost.utils.L
import com.pitchedapps.frost.utils.frostEvent import com.pitchedapps.frost.utils.frostEvent
import com.pitchedapps.frost.widgets.NotificationWidget import com.pitchedapps.frost.widgets.NotificationWidget
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.isActive import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.coroutines.yield import kotlinx.coroutines.yield
import javax.inject.Inject
/** /**
* Created by Allan Wang on 2017-06-14. * Created by Allan Wang on 2017-06-14.
* *
* Service to manage notifications * Service to manage notifications Will periodically check through all accounts in the db and send
* Will periodically check through all accounts in the db and send notifications when appropriate * notifications when appropriate
* *
* All fetching is done through parsers * All fetching is done through parsers
*/ */
@AndroidEntryPoint @AndroidEntryPoint
class NotificationService : BaseJobService() { class NotificationService : BaseJobService() {
@Inject @Inject lateinit var prefs: Prefs
lateinit var prefs: Prefs
@Inject @Inject lateinit var notifDao: NotificationDao
lateinit var notifDao: NotificationDao
@Inject @Inject lateinit var cookieDao: CookieDao
lateinit var cookieDao: CookieDao
override fun onStopJob(params: JobParameters?): Boolean { override fun onStopJob(params: JobParameters?): Boolean {
super.onStopJob(params) super.onStopJob(params)
prepareFinish(true) prepareFinish(true)
return false 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 /**
* Implemented fetch to also notify when an error occurs Also normalized the output to return the
private fun prepareFinish(abrupt: Boolean) { * number of notifications received
if (preparedFinish) */
return private suspend fun fetch(jobId: Int, type: NotificationType, cookie: CookieEntity): Int {
preparedFinish = true val count = type.fetch(this, cookie, prefs, notifDao)
val time = System.currentTimeMillis() - startTime if (count < 0) {
L.i { "Notification service has ${if (abrupt) "finished abruptly" else "finished"} in $time ms" } if (jobId == NOTIFICATION_JOB_NOW)
frostEvent( generalNotification(666, R.string.error_notification, BuildConfig.DEBUG)
"NotificationTime", return 0
"Type" to (if (abrupt) "Service force stop" else "Service"),
"IM Included" to prefs.notificationsInstantMessages,
"Duration" to time
)
} }
return count
}
override fun onStartJob(params: JobParameters?): Boolean { private fun logNotif(text: String): NotificationContent? {
super.onStartJob(params) L.eThrow("NotificationService: $text")
L.i { "Fetching notifications" } return null
launch { }
try {
sendNotifications(params)
} finally {
if (!isActive)
prepareFinish(false)
jobFinished(params, false)
}
}
return true
}
private suspend fun sendNotifications(params: JobParameters?): Unit = private fun generalNotification(id: Int, textRes: Int, withDefaults: Boolean) {
withContext(Dispatchers.Default) { val notifBuilder =
val currentId = prefs.userId frostNotification(NOTIF_CHANNEL_GENERAL)
val cookies = cookieDao.selectAll() .setFrostAlert(this, withDefaults, prefs.notificationRingtone, prefs)
yield() .setContentTitle(string(R.string.frost_name))
val jobId = params?.extras?.getInt(NOTIFICATION_PARAM_ID, -1) ?: -1 .setContentText(string(textRes))
var notifCount = 0 NotificationManagerCompat.from(this).notify(id, notifBuilder.build())
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())
}
} }

View File

@ -36,84 +36,76 @@ import com.pitchedapps.frost.prefs.Prefs
import com.pitchedapps.frost.utils.L import com.pitchedapps.frost.utils.L
import com.pitchedapps.frost.utils.frostUri 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_GENERAL = "general"
const val NOTIF_CHANNEL_MESSAGES = "messages" const val NOTIF_CHANNEL_MESSAGES = "messages"
fun setupNotificationChannels(c: Context, themeProvider: ThemeProvider) { fun setupNotificationChannels(c: Context, themeProvider: ThemeProvider) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
val manager = c.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager val manager = c.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
val appName = c.string(R.string.frost_name) val appName = c.string(R.string.frost_name)
val msg = c.string(R.string.messages) val msg = c.string(R.string.messages)
manager.createNotificationChannel(NOTIF_CHANNEL_GENERAL, appName, themeProvider) manager.createNotificationChannel(NOTIF_CHANNEL_GENERAL, appName, themeProvider)
manager.createNotificationChannel(NOTIF_CHANNEL_MESSAGES, "$appName: $msg", themeProvider) manager.createNotificationChannel(NOTIF_CHANNEL_MESSAGES, "$appName: $msg", themeProvider)
manager.notificationChannels manager.notificationChannels
.filter { .filter { it.id != NOTIF_CHANNEL_GENERAL && it.id != NOTIF_CHANNEL_MESSAGES }
it.id != NOTIF_CHANNEL_GENERAL && .forEach { manager.deleteNotificationChannel(it.id) }
it.id != NOTIF_CHANNEL_MESSAGES L.d {
} "Created notification channels: ${manager.notificationChannels.size} channels, ${manager.notificationChannelGroups.size} groups"
.forEach { manager.deleteNotificationChannel(it.id) } }
L.d { "Created notification channels: ${manager.notificationChannels.size} channels, ${manager.notificationChannelGroups.size} groups" }
} }
@RequiresApi(Build.VERSION_CODES.O) @RequiresApi(Build.VERSION_CODES.O)
private fun NotificationManager.createNotificationChannel( private fun NotificationManager.createNotificationChannel(
id: String, id: String,
name: String, name: String,
themeProvider: ThemeProvider themeProvider: ThemeProvider
): NotificationChannel { ): NotificationChannel {
val channel = NotificationChannel( val channel = NotificationChannel(id, name, NotificationManager.IMPORTANCE_DEFAULT)
id, channel.enableLights(true)
name, NotificationManager.IMPORTANCE_DEFAULT channel.lightColor = themeProvider.accentColor
) channel.lockscreenVisibility = Notification.VISIBILITY_PUBLIC
channel.enableLights(true) createNotificationChannel(channel)
channel.lightColor = themeProvider.accentColor return channel
channel.lockscreenVisibility = Notification.VISIBILITY_PUBLIC
createNotificationChannel(channel)
return channel
} }
fun Context.frostNotification(id: String) = fun Context.frostNotification(id: String) =
NotificationCompat.Builder(this, id) NotificationCompat.Builder(this, id).apply {
.apply { setSmallIcon(R.drawable.frost_f_24)
setSmallIcon(R.drawable.frost_f_24) setAutoCancel(true)
setAutoCancel(true) setOnlyAlertOnce(true)
setOnlyAlertOnce(true) setStyle(NotificationCompat.BigTextStyle())
setStyle(NotificationCompat.BigTextStyle()) color = color(R.color.frost_notification_accent)
color = color(R.color.frost_notification_accent) }
}
/** /**
* Dictates whether a notification should have sound/vibration/lights or not * Dictates whether a notification should have sound/vibration/lights or not Delegates to channels
* Delegates to channels if Android O and up * if Android O and up Otherwise uses our provided preferences
* Otherwise uses our provided preferences
*/ */
fun NotificationCompat.Builder.setFrostAlert( fun NotificationCompat.Builder.setFrostAlert(
context: Context, context: Context,
enable: Boolean, enable: Boolean,
ringtone: String, ringtone: String,
prefs: Prefs prefs: Prefs
): NotificationCompat.Builder { ): NotificationCompat.Builder {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
setGroupAlertBehavior( setGroupAlertBehavior(
if (enable) NotificationCompat.GROUP_ALERT_CHILDREN if (enable) NotificationCompat.GROUP_ALERT_CHILDREN
else NotificationCompat.GROUP_ALERT_SUMMARY else NotificationCompat.GROUP_ALERT_SUMMARY
) )
} else if (!enable) { } else if (!enable) {
setDefaults(0) setDefaults(0)
} else { } else {
var defaults = 0 var defaults = 0
if (prefs.notificationVibrate) defaults = defaults or Notification.DEFAULT_VIBRATE if (prefs.notificationVibrate) defaults = defaults or Notification.DEFAULT_VIBRATE
if (prefs.notificationSound) { if (prefs.notificationSound) {
if (ringtone.isNotBlank()) setSound(context.frostUri(ringtone)) if (ringtone.isNotBlank()) setSound(context.frostUri(ringtone))
else defaults = defaults or Notification.DEFAULT_SOUND else defaults = defaults or Notification.DEFAULT_SOUND
}
if (prefs.notificationLights) defaults = defaults or Notification.DEFAULT_LIGHTS
setDefaults(defaults)
} }
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" const val NOTIFICATION_PARAM_ID = "notif_param_id"
fun JobInfo.Builder.setExtras(id: Int): JobInfo.Builder { fun JobInfo.Builder.setExtras(id: Int): JobInfo.Builder {
val bundle = PersistableBundle() val bundle = PersistableBundle()
bundle.putInt(NOTIFICATION_PARAM_ID, id) bundle.putInt(NOTIFICATION_PARAM_ID, id)
return setExtras(bundle) return setExtras(bundle)
} }
/** /**
* interval is # of min, which must be at least 15 * interval is # of min, which must be at least 15 returns false if an error occurs; true otherwise
* returns false if an error occurs; true otherwise
*/ */
inline fun <reified T : JobService> Context.scheduleJob(id: Int, minutes: Long): Boolean { inline fun <reified T : JobService> Context.scheduleJob(id: Int, minutes: Long): Boolean {
val scheduler = getSystemService(Context.JOB_SCHEDULER_SERVICE) as JobScheduler val scheduler = getSystemService(Context.JOB_SCHEDULER_SERVICE) as JobScheduler
scheduler.cancel(id) scheduler.cancel(id)
if (minutes < 0L) return true if (minutes < 0L) return true
val serviceComponent = ComponentName(this, T::class.java) val serviceComponent = ComponentName(this, T::class.java)
val builder = JobInfo.Builder(id, serviceComponent) val builder =
.setPeriodic(minutes * 60000) JobInfo.Builder(id, serviceComponent)
.setExtras(id) .setPeriodic(minutes * 60000)
.setPersisted(true) .setExtras(id)
.setRequiredNetworkType(JobInfo.NETWORK_TYPE_ANY) // TODO add options .setPersisted(true)
val result = scheduler.schedule(builder.build()) .setRequiredNetworkType(JobInfo.NETWORK_TYPE_ANY) // TODO add options
if (result <= 0) { val result = scheduler.schedule(builder.build())
L.eThrow("${T::class.java.simpleName} scheduler failed") if (result <= 0) {
return false L.eThrow("${T::class.java.simpleName} scheduler failed")
} return false
return true }
return true
} }
/** /** Run notification job right now */
* Run notification job right now
*/
inline fun <reified T : JobService> Context.fetchJob(id: Int): Boolean { inline fun <reified T : JobService> Context.fetchJob(id: Int): Boolean {
val scheduler = getSystemService(Context.JOB_SCHEDULER_SERVICE) as JobScheduler val scheduler = getSystemService(Context.JOB_SCHEDULER_SERVICE) as JobScheduler
val serviceComponent = ComponentName(this, T::class.java) val serviceComponent = ComponentName(this, T::class.java)
val builder = JobInfo.Builder(id, serviceComponent) val builder =
.setMinimumLatency(0L) JobInfo.Builder(id, serviceComponent)
.setExtras(id) .setMinimumLatency(0L)
.setOverrideDeadline(2000L) .setExtras(id)
.setRequiredNetworkType(JobInfo.NETWORK_TYPE_ANY) .setOverrideDeadline(2000L)
val result = scheduler.schedule(builder.build()) .setRequiredNetworkType(JobInfo.NETWORK_TYPE_ANY)
if (result <= 0) { val result = scheduler.schedule(builder.build())
L.eThrow("${T::class.java.simpleName} instant scheduler failed") if (result <= 0) {
return false L.eThrow("${T::class.java.simpleName} instant scheduler failed")
} return false
return true }
return true
} }

View File

@ -32,12 +32,11 @@ import javax.inject.Inject
@AndroidEntryPoint @AndroidEntryPoint
class UpdateReceiver : BroadcastReceiver() { class UpdateReceiver : BroadcastReceiver() {
@Inject @Inject lateinit var prefs: Prefs
lateinit var prefs: Prefs
override fun onReceive(context: Context, intent: Intent) { override fun onReceive(context: Context, intent: Intent) {
if (intent.action != Intent.ACTION_MY_PACKAGE_REPLACED) return if (intent.action != Intent.ACTION_MY_PACKAGE_REPLACED) return
L.d { "Frost has updated" } L.d { "Frost has updated" }
context.scheduleNotifications(prefs.notificationFreq) // Update notifications context.scheduleNotifications(prefs.notificationFreq) // Update notifications
} }
} }

View File

@ -35,173 +35,170 @@ import com.pitchedapps.frost.utils.frostSnackbar
import com.pitchedapps.frost.utils.launchTabCustomizerActivity import com.pitchedapps.frost.utils.launchTabCustomizerActivity
import com.pitchedapps.frost.views.KPrefTextSeekbar 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 = { 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 = {
text(R.string.theme, prefs::theme, { themeProvider.setTheme(it) }) { materialDialog {
onClick = { title(R.string.theme)
materialDialog { listItemsSingleChoice(
title(R.string.theme) items = Theme.values().map { string(it.textRes) },
listItemsSingleChoice( initialSelection = item.pref
items = Theme.values().map { string(it.textRes) }, ) { _, index, _ ->
initialSelection = item.pref if (item.pref != index) {
) { _, index, _ -> 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()
shouldRestartMain() shouldRestartMain()
}
) {
dependsOnCustom()
allowCustomAlpha = false
}
colorPicker(
R.string.accent_color, prefs::customAccentColor,
{
prefs.customAccentColor = it
reload() 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) activityThemer.setFrostTheme(forceTransparent = true)
shouldRestartMain() themeExterior()
}
) {
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() 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() 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( checkbox(
R.string.main_activity_layout, R.string.tint_nav,
prefs::mainActivityLayoutType, prefs::tintNavBar,
{ prefs.mainActivityLayoutType = it } {
) { prefs.tintNavBar = it
textGetter = { string(prefs.mainActivityLayout.titleRes) } frostNavigationBar(prefs, themeProvider)
onClick = { setFrostResult(REQUEST_NAV)
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)
}
}
}
}
} }
) {
descRes = R.string.tint_nav_desc
}
plainText(R.string.main_tabs) { list.add(
descRes = R.string.main_tabs_desc KPrefTextSeekbar(
onClick = { launchTabCustomizerActivity() } KPrefSeekbar.KPrefSeekbarBuilder(
} globalOptions,
R.string.web_text_scaling,
checkbox( prefs::webTextScaling
R.string.tint_nav, prefs::tintNavBar, ) {
{ prefs.webTextScaling = it
prefs.tintNavBar = it setFrostResult(REQUEST_TEXT_ZOOM)
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)
}
)
) )
)
checkbox( checkbox(R.string.enforce_black_media_bg, prefs::blackMediaBg, { prefs.blackMediaBg = it }) {
R.string.enforce_black_media_bg, prefs::blackMediaBg, descRes = R.string.enforce_black_media_bg_desc
{ }
prefs.blackMediaBg = it
}
) {
descRes = R.string.enforce_black_media_bg_desc
}
} }

View File

@ -20,76 +20,86 @@ import ca.allanwang.kau.kpref.activity.KPrefAdapterBuilder
import com.pitchedapps.frost.R import com.pitchedapps.frost.R
import com.pitchedapps.frost.activities.SettingsActivity 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 = { 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 }) { checkbox(
descRes = R.string.auto_refresh_feed_desc 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 }) { checkbox(
descRes = R.string.fancy_animations_desc R.string.overlay_swipe,
prefs::overlayEnabled,
{
prefs.overlayEnabled = it
shouldRefreshMain()
} }
) {
descRes = R.string.overlay_swipe_desc
}
checkbox( checkbox(
R.string.overlay_swipe, R.string.overlay_full_screen_swipe,
prefs::overlayEnabled, prefs::overlayFullScreenSwipe,
{ prefs.overlayEnabled = it; shouldRefreshMain() } { prefs.overlayFullScreenSwipe = it }
) { ) {
descRes = R.string.overlay_swipe_desc 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( checkbox(R.string.enable_pip, prefs::enablePip, { prefs.enablePip = it }) {
R.string.overlay_full_screen_swipe, descRes = R.string.enable_pip_desc
prefs::overlayFullScreenSwipe, }
{ prefs.overlayFullScreenSwipe = it }
) {
descRes = R.string.overlay_full_screen_swipe_desc
}
checkbox( // Not available for desktop user agent for now
R.string.open_links_in_default, // plainText(R.string.autoplay_settings) {
prefs::linksInDefaultApp, // descRes = R.string.autoplay_settings_desc
{ prefs.linksInDefaultApp = it } // onClick = {
) { // launchWebOverlay("${FB_URL_BASE}settings/videos/")
descRes = R.string.open_links_in_default_desc // }
} // }
checkbox(R.string.viewpager_swipe, prefs::viewpagerSwipe, { prefs.viewpagerSwipe = it }) { checkbox(R.string.exit_confirmation, prefs::exitConfirmation, { prefs.exitConfirmation = it }) {
descRes = R.string.viewpager_swipe_desc descRes = R.string.exit_confirmation_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
}
} }

View File

@ -40,123 +40,114 @@ import com.pitchedapps.frost.prefs.Prefs
import com.pitchedapps.frost.utils.L import com.pitchedapps.frost.utils.L
import com.pitchedapps.frost.utils.frostUriFromFile import com.pitchedapps.frost.utils.frostUriFromFile
import com.pitchedapps.frost.utils.sendFrostEmail import com.pitchedapps.frost.utils.sendFrostEmail
import java.io.File
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.io.File
/** /**
* Created by Allan Wang on 2017-06-30. * Created by Allan Wang on 2017-06-30.
* *
* A sub pref section that is enabled through a hidden preference * A sub pref section that is enabled through a hidden preference Each category will load a page,
* Each category will load a page, extract the contents, remove private info, and create a report * extract the contents, remove private info, and create a report
*/ */
fun SettingsActivity.getDebugPrefs(): KPrefAdapterBuilder.() -> Unit = { fun SettingsActivity.getDebugPrefs(): KPrefAdapterBuilder.() -> Unit = {
plainText(R.string.disclaimer) { descRes = R.string.debug_disclaimer_info }
plainText(R.string.disclaimer) { plainText(R.string.debug_web) {
descRes = R.string.debug_disclaimer_info descRes = R.string.debug_web_desc
} onClick = { this@getDebugPrefs.startActivityForResult<DebugActivity>(ACTIVITY_REQUEST_DEBUG) }
}
plainText(R.string.debug_web) { plainText(R.string.debug_parsers) {
descRes = R.string.debug_web_desc descRes = R.string.debug_parsers_desc
onClick = onClick = {
{ this@getDebugPrefs.startActivityForResult<DebugActivity>(ACTIVITY_REQUEST_DEBUG) } val parsers = arrayOf(NotifParser, MessageParser, SearchParser)
}
plainText(R.string.debug_parsers) { materialDialog {
descRes = R.string.debug_parsers_desc // noinspection CheckResult
onClick = { 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) attempt =
launch(Dispatchers.IO) {
materialDialog { try {
// noinspection CheckResult val data = parser.parse(fbCookie.webCookie)
listItems(items = parsers.map { string(it.nameRes) }) { dialog, position, _ -> withMainContext {
dialog.dismiss() loading.dismiss()
val parser = parsers[position] createEmail(parser, data?.data, prefs)
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)
}
}
} }
} catch (e: Exception) {
createEmail(parser, "Error: ${e.message}", prefs)
}
} }
} }
}
} }
}
} }
private fun Context.createEmail(parser: FrostParser<*>, content: Any?, prefs: Prefs) = private fun Context.createEmail(parser: FrostParser<*>, content: Any?, prefs: Prefs) =
sendFrostEmail( sendFrostEmail(
"${string(R.string.debug_report)}: ${parser::class.java.simpleName}", "${string(R.string.debug_report)}: ${parser::class.java.simpleName}",
prefs = prefs prefs = prefs
) { ) {
addItem("Url", parser.url) addItem("Url", parser.url)
addItem("Contents", "$content") addItem("Contents", "$content")
} }
private const val ZIP_NAME = "debug" private const val ZIP_NAME = "debug"
fun SettingsActivity.sendDebug(url: String, html: String?) { fun SettingsActivity.sendDebug(url: String, html: String?) {
val downloader = OfflineWebsite( val downloader =
url, OfflineWebsite(
cookie = fbCookie.webCookie ?: "", url,
baseUrl = FB_URL_BASE, cookie = fbCookie.webCookie ?: "",
html = html, baseUrl = FB_URL_BASE,
baseDir = DebugActivity.baseDir(this) html = html,
baseDir = DebugActivity.baseDir(this)
) )
val job = Job() val job = Job()
val md = materialDialog { val md = materialDialog {
title(R.string.parsing_data) title(R.string.parsing_data)
// TODO remove dialog? No progress ui // TODO remove dialog? No progress ui
negativeButton(R.string.kau_cancel) { it.dismiss() } negativeButton(R.string.kau_cancel) { it.dismiss() }
cancelOnTouchOutside(false) cancelOnTouchOutside(false)
onDismiss { job.cancel() } onDismiss { job.cancel() }
} }
val progressFlow = MutableStateFlow(0) val progressFlow = MutableStateFlow(0)
// progressFlow.onEach { md.setProgress(it) }.launchIn(this) // progressFlow.onEach { md.setProgress(it) }.launchIn(this)
launchMain { launchMain {
val success = downloader.loadAndZip(ZIP_NAME) { val success = downloader.loadAndZip(ZIP_NAME) { progressFlow.tryEmit(it) }
progressFlow.tryEmit(it) md.dismiss()
} if (success) {
md.dismiss() val zipUri = frostUriFromFile(File(downloader.baseDir, "$ZIP_NAME.zip"))
if (success) { L.i { "Sending debug zip with uri $zipUri" }
val zipUri = frostUriFromFile( sendFrostEmail(R.string.debug_report_email_title, prefs = prefs) {
File(downloader.baseDir, "$ZIP_NAME.zip") addItem("Url", url)
) addAttachment(zipUri)
L.i { "Sending debug zip with uri $zipUri" } extras = { type = "application/zip" }
sendFrostEmail(R.string.debug_report_email_title, prefs = prefs) { }
addItem("Url", url) } else {
addAttachment(zipUri) toast(R.string.error_generic)
extras = {
type = "application/zip"
}
}
} else {
toast(R.string.error_generic)
}
} }
}
} }

Some files were not shown because too many files have changed in this diff Show More