1
0
mirror of https://github.com/AllanWang/Frost-for-Facebook.git synced 2024-09-16 20:12:25 +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
class FrostTestRunner : AndroidJUnitRunner() {
override fun newApplication(
cl: ClassLoader?,
className: String?,
context: Context?
): Application {
return super.newApplication(cl, HiltTestApplication::class.java.name, context)
}
override fun newApplication(
cl: ClassLoader?,
className: String?,
context: Context?
): Application {
return super.newApplication(cl, HiltTestApplication::class.java.name, context)
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -29,6 +29,11 @@ import com.pitchedapps.frost.utils.ARG_TEXT
import com.pitchedapps.frost.utils.isIndirectImageUrl
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertTrue
import okhttp3.internal.closeQuietly
import okhttp3.mockwebserver.Dispatcher
import okhttp3.mockwebserver.MockResponse
@ -42,140 +47,125 @@ import org.junit.Ignore
import org.junit.Rule
import org.junit.Test
import org.junit.rules.Timeout
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertTrue
@HiltAndroidTest
class ImageActivityTest {
@get:Rule(order = 0)
val hildAndroidRule = HiltAndroidRule(this)
@get:Rule(order = 0) val hildAndroidRule = HiltAndroidRule(this)
@get:Rule(order = 1)
val activityRule = activityRule<ImageActivity>(
intentAction = {
putExtra(ARG_IMAGE_URL, TEST_FORMATTED_URL)
}
@get:Rule(order = 1)
val activityRule =
activityRule<ImageActivity>(intentAction = { putExtra(ARG_IMAGE_URL, TEST_FORMATTED_URL) })
@get:Rule(order = 2) val globalTimeout: Timeout = Timeout.seconds(15)
lateinit var mockServer: MockWebServer
@Before
fun before() {
mockServer = mockServer()
}
@After
fun after() {
mockServer.closeQuietly()
}
@Test
fun initializesSuccessfully() =
launchScenario(mockServer.url("image").toString()) {
// Verify no crash
}
@Test
fun validImageTest() =
launchScenario(mockServer.url("image").toString()) {
mockServer.takeRequest()
assertEquals(1, mockServer.requestCount, "One http request expected")
// assertEquals(
// FabStates.DOWNLOAD,
// fabAction,
// "Image should be successful, image should be downloaded"
// )
assertFalse(binding.error.isVisible, "Error should not be shown")
val tempFile = assertNotNull(tempFile, "Temp file not created")
assertTrue(tempFile.exists(), "Image should be located at temp file")
assertTrue(
System.currentTimeMillis() - tempFile.lastModified() < 2000L,
"Image should have been modified within the last few seconds"
)
assertNull(errorRef, "No error should exist")
tempFile.delete()
}
@Test
@Ignore("apparently this fails")
fun invalidImageTest() =
launchScenario(mockServer.url("text").toString()) {
mockServer.takeRequest()
assertEquals(1, mockServer.requestCount, "One http request expected")
assertTrue(binding.error.isVisible, "Error should be shown")
// assertEquals(
// FabStates.ERROR,
// fabAction,
// "Text should not be a valid image format, error state expected"
// )
assertEquals("Image format not supported", errorRef?.message, "Error message mismatch")
assertFalse(tempFile?.exists() == true, "Temp file should have been removed")
}
@Test
fun errorTest() =
launchScenario(mockServer.url("error").toString()) {
mockServer.takeRequest()
assertEquals(1, mockServer.requestCount, "One http request expected")
assertTrue(binding.error.isVisible, "Error should be shown")
// assertEquals(FabStates.ERROR, fabAction, "Error response code, error state
// expected")
assertEquals(
"Unsuccessful response for image: Error mock response",
errorRef?.message,
"Error message mismatch"
)
assertFalse(tempFile?.exists() == true, "Temp file should have been removed")
}
private fun launchScenario(
imageUrl: String,
text: String? = null,
cookie: String? = null,
action: ImageActivity.() -> Unit
) {
assertFalse(
imageUrl.isIndirectImageUrl,
"For simplicity, urls that are direct will be used without modifications in the production code."
)
val intent =
Intent(ApplicationProvider.getApplicationContext(), ImageActivity::class.java).apply {
putExtra(ARG_IMAGE_URL, imageUrl)
putExtra(ARG_TEXT, text)
putExtra(ARG_COOKIE, cookie)
}
ActivityScenario.launch<ImageActivity>(intent).use { it.onActivity(action) }
}
@get:Rule(order = 2)
val globalTimeout: Timeout = Timeout.seconds(15)
lateinit var mockServer: MockWebServer
@Before
fun before() {
mockServer = mockServer()
}
@After
fun after() {
mockServer.closeQuietly()
}
@Test
fun initializesSuccessfully() = launchScenario(mockServer.url("image").toString()) {
// Verify no crash
}
@Test
fun validImageTest() = launchScenario(mockServer.url("image").toString()) {
mockServer.takeRequest()
assertEquals(1, mockServer.requestCount, "One http request expected")
// assertEquals(
// FabStates.DOWNLOAD,
// fabAction,
// "Image should be successful, image should be downloaded"
// )
assertFalse(binding.error.isVisible, "Error should not be shown")
val tempFile = assertNotNull(tempFile, "Temp file not created")
assertTrue(tempFile.exists(), "Image should be located at temp file")
assertTrue(
System.currentTimeMillis() - tempFile.lastModified() < 2000L,
"Image should have been modified within the last few seconds"
)
assertNull(errorRef, "No error should exist")
tempFile.delete()
}
@Test
@Ignore("apparently this fails")
fun invalidImageTest() = launchScenario(mockServer.url("text").toString()) {
mockServer.takeRequest()
assertEquals(1, mockServer.requestCount, "One http request expected")
assertTrue(binding.error.isVisible, "Error should be shown")
// assertEquals(
// FabStates.ERROR,
// fabAction,
// "Text should not be a valid image format, error state expected"
// )
assertEquals(
"Image format not supported",
errorRef?.message,
"Error message mismatch"
)
assertFalse(tempFile?.exists() == true, "Temp file should have been removed")
}
@Test
fun errorTest() = launchScenario(mockServer.url("error").toString()) {
mockServer.takeRequest()
assertEquals(1, mockServer.requestCount, "One http request expected")
assertTrue(binding.error.isVisible, "Error should be shown")
// assertEquals(FabStates.ERROR, fabAction, "Error response code, error state expected")
assertEquals(
"Unsuccessful response for image: Error mock response",
errorRef?.message,
"Error message mismatch"
)
assertFalse(tempFile?.exists() == true, "Temp file should have been removed")
}
private fun launchScenario(
imageUrl: String,
text: String? = null,
cookie: String? = null,
action: ImageActivity.() -> Unit
) {
assertFalse(
imageUrl.isIndirectImageUrl,
"For simplicity, urls that are direct will be used without modifications in the production code."
)
val intent =
Intent(ApplicationProvider.getApplicationContext(), ImageActivity::class.java).apply {
putExtra(ARG_IMAGE_URL, imageUrl)
putExtra(ARG_TEXT, text)
putExtra(ARG_COOKIE, cookie)
private fun mockServer(): MockWebServer {
val img = Buffer()
img.writeAll(getResource("bayer-pattern.jpg").source())
return MockWebServer().apply {
dispatcher =
object : Dispatcher() {
override fun dispatch(request: RecordedRequest): MockResponse =
when {
request.path?.contains("text") == true ->
MockResponse().setResponseCode(200).setBody("Valid mock text response")
request.path?.contains("image") == true ->
MockResponse().setResponseCode(200).setBody(img)
else -> MockResponse().setResponseCode(404).setBody("Error mock response")
}
ActivityScenario.launch<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
class IntroActivityTest {
@get:Rule(order = 0)
val hildAndroidRule = HiltAndroidRule(this)
@get:Rule(order = 0) val hildAndroidRule = HiltAndroidRule(this)
@get:Rule(order = 1)
val activityRule = activityRule<IntroActivity>()
@get:Rule(order = 1) val activityRule = activityRule<IntroActivity>()
@Test
fun initializesSuccessfully() {
activityRule.scenario.use {
it.onActivity {
// Verify no crash
}
}
@Test
fun initializesSuccessfully() {
activityRule.scenario.use {
it.onActivity {
// Verify no crash
}
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -18,56 +18,54 @@ package com.pitchedapps.frost.db
import com.pitchedapps.frost.facebook.FbItem
import com.pitchedapps.frost.facebook.defaultTabs
import kotlinx.coroutines.runBlocking
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlinx.coroutines.runBlocking
class GenericDbTest : BaseDbTest() {
private val dao get() = db.genericDao()
private val dao
get() = db.genericDao()
/**
* Note that order is also preserved here
*/
@Test
fun save() {
val tabs = listOf(
FbItem.ACTIVITY_LOG,
FbItem.BIRTHDAYS,
FbItem.EVENTS,
FbItem.MARKETPLACE,
FbItem.ACTIVITY_LOG
/** Note that order is also preserved here */
@Test
fun save() {
val tabs =
listOf(
FbItem.ACTIVITY_LOG,
FbItem.BIRTHDAYS,
FbItem.EVENTS,
FbItem.MARKETPLACE,
FbItem.ACTIVITY_LOG
)
runBlocking {
dao.saveTabs(tabs)
assertEquals(tabs, dao.getTabs(), "Tab saving failed")
val newTabs = listOf(FbItem.PAGES, FbItem.MENU)
dao.saveTabs(newTabs)
assertEquals(newTabs, dao.getTabs(), "Tab overwrite failed")
}
}
@Test
fun defaultRetrieve() {
runBlocking { assertEquals(defaultTabs(), dao.getTabs(), "Default retrieval failed") }
}
@Test
fun ignoreErrors() {
runBlocking {
dao._save(
GenericEntity(
GenericDao.TYPE_TABS,
"${FbItem.ACTIVITY_LOG.name},unknown,${FbItem.EVENTS.name}"
)
runBlocking {
dao.saveTabs(tabs)
assertEquals(tabs, dao.getTabs(), "Tab saving failed")
val newTabs = listOf(FbItem.PAGES, FbItem.MENU)
dao.saveTabs(newTabs)
assertEquals(newTabs, dao.getTabs(), "Tab overwrite failed")
}
}
@Test
fun defaultRetrieve() {
runBlocking {
assertEquals(defaultTabs(), dao.getTabs(), "Default retrieval failed")
}
}
@Test
fun ignoreErrors() {
runBlocking {
dao._save(
GenericEntity(
GenericDao.TYPE_TABS,
"${FbItem.ACTIVITY_LOG.name},unknown,${FbItem.EVENTS.name}"
)
)
assertEquals(
listOf(FbItem.ACTIVITY_LOG, FbItem.EVENTS),
dao.getTabs(),
"Tab fetching does not ignore unknown names"
)
}
)
assertEquals(
listOf(FbItem.ACTIVITY_LOG, FbItem.EVENTS),
dao.getTabs(),
"Tab fetching does not ignore unknown names"
)
}
}
}

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

View File

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

View File

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

View File

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

View File

@ -45,97 +45,90 @@ import com.pitchedapps.frost.utils.L
import com.pitchedapps.frost.utils.launchNewTask
import com.pitchedapps.frost.utils.loadAssets
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.launch
import java.util.ArrayList
import javax.inject.Inject
import kotlinx.coroutines.launch
/**
* Created by Allan Wang on 2017-05-28.
*/
/** Created by Allan Wang on 2017-05-28. */
@AndroidEntryPoint
class StartActivity : KauBaseActivity() {
@Inject
lateinit var fbCookie: FbCookie
@Inject lateinit var fbCookie: FbCookie
@Inject
lateinit var prefs: Prefs
@Inject lateinit var prefs: Prefs
@Inject
lateinit var themeProvider: ThemeProvider
@Inject lateinit var themeProvider: ThemeProvider
@Inject
lateinit var cookieDao: CookieDao
@Inject lateinit var cookieDao: CookieDao
@Inject
lateinit var genericDao: GenericDao
@Inject lateinit var genericDao: GenericDao
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
if (!buildIsLollipopAndUp) { // not supported
showInvalidSdkView()
return
}
if (!buildIsLollipopAndUp) { // not supported
showInvalidSdkView()
return
}
try {
// TODO add better descriptions
CookieManager.getInstance()
} catch (e: Exception) {
L.e(e) { "No cookiemanager instance" }
showInvalidWebView()
}
try {
// TODO add better descriptions
CookieManager.getInstance()
} catch (e: Exception) {
L.e(e) { "No cookiemanager instance" }
showInvalidWebView()
}
launch {
try {
val authDefer = BiometricUtils.authenticate(this@StartActivity, prefs)
fbCookie.switchBackUser()
val cookies = ArrayList(cookieDao.selectAll())
L.i { "Cookies loaded at time ${System.currentTimeMillis()}" }
L._d {
"Cookies: ${
launch {
try {
val authDefer = BiometricUtils.authenticate(this@StartActivity, prefs)
fbCookie.switchBackUser()
val cookies = ArrayList(cookieDao.selectAll())
L.i { "Cookies loaded at time ${System.currentTimeMillis()}" }
L._d {
"Cookies: ${
cookies.joinToString(
"\t",
transform = CookieEntity::toSensitiveString
)
}"
}
loadAssets(themeProvider)
authDefer.await()
when {
cookies.isEmpty() -> launchNewTask<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() =
showInvalidView(R.string.error_webview)
private fun showInvalidWebView() = showInvalidView(R.string.error_webview)
private fun showInvalidSdkView() {
val text = String.format(string(R.string.error_sdk), Build.VERSION.SDK_INT)
showInvalidView(text)
}
private fun showInvalidSdkView() {
val text = String.format(string(R.string.error_sdk), Build.VERSION.SDK_INT)
showInvalidView(text)
}
private fun showInvalidView(textRes: Int) =
showInvalidView(string(textRes))
private fun showInvalidView(textRes: Int) = showInvalidView(string(textRes))
private fun showInvalidView(text: String) {
setContentView(R.layout.activity_invalid)
findViewById<ImageView>(R.id.invalid_icon)
.setIcon(GoogleMaterial.Icon.gmd_adb, -1, Color.WHITE)
findViewById<TextView>(R.id.invalid_text).text = text
}
private fun showInvalidView(text: String) {
setContentView(R.layout.activity_invalid)
findViewById<ImageView>(R.id.invalid_icon).setIcon(GoogleMaterial.Icon.gmd_adb, -1, Color.WHITE)
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 javax.inject.Inject
/**
* Created by Allan Wang on 2017-06-26.
*/
/** Created by Allan Wang on 2017-06-26. */
@AndroidEntryPoint
class AboutActivity : AboutActivityBase(null) {
@Inject
lateinit var prefs: Prefs
@Inject lateinit var prefs: Prefs
@Inject
lateinit var themeProvider: ThemeProvider
@Inject lateinit var themeProvider: ThemeProvider
override fun Configs.buildConfigs() {
textColor = themeProvider.textColor
accentColor = themeProvider.accentColor
backgroundColor = themeProvider.bgColor.withMinAlpha(200)
cutoutForeground = themeProvider.accentColor
cutoutDrawableRes = R.drawable.frost_f_200
faqPageTitleRes = R.string.faq_title
faqXmlRes = R.xml.frost_faq
faqParseNewLine = false
override fun Configs.buildConfigs() {
textColor = themeProvider.textColor
accentColor = themeProvider.accentColor
backgroundColor = themeProvider.bgColor.withMinAlpha(200)
cutoutForeground = themeProvider.accentColor
cutoutDrawableRes = R.drawable.frost_f_200
faqPageTitleRes = R.string.faq_title
faqXmlRes = R.xml.frost_faq
faqParseNewLine = false
}
var lastClick = -1L
var clickCount = 0
override fun postInflateMainPage(adapter: FastItemThemedAdapter<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
var clickCount = 0
class ViewHolder(v: View) : RecyclerView.ViewHolder(v) {
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,
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
)
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)
}
}
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)
}
}
set.applyTo(container)
}
}
}
}

View File

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

View File

@ -35,110 +35,97 @@ import com.pitchedapps.frost.utils.ActivityThemer
import com.pitchedapps.frost.utils.L
import com.pitchedapps.frost.utils.createFreshDir
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.CoroutineExceptionHandler
import java.io.File
import javax.inject.Inject
import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine
import kotlinx.coroutines.CoroutineExceptionHandler
/**
* Created by Allan Wang on 05/01/18.
*/
/** Created by Allan Wang on 05/01/18. */
@AndroidEntryPoint
class DebugActivity : KauBaseActivity() {
companion object {
const val RESULT_URL = "extra_result_url"
const val RESULT_SCREENSHOT = "extra_result_screenshot"
const val RESULT_BODY = "extra_result_body"
fun baseDir(context: Context) = File(context.externalCacheDir, "offline_debug")
companion object {
const val RESULT_URL = "extra_result_url"
const val RESULT_SCREENSHOT = "extra_result_screenshot"
const val RESULT_BODY = "extra_result_body"
fun baseDir(context: Context) = File(context.externalCacheDir, "offline_debug")
}
@Inject lateinit var activityThemer: ActivityThemer
@Inject lateinit var themeProvider: ThemeProvider
lateinit var binding: ActivityDebugBinding
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityDebugBinding.inflate(layoutInflater)
setContentView(binding.root)
binding.init()
}
fun ActivityDebugBinding.init() {
setSupportActionBar(toolbar)
supportActionBar?.apply {
setDisplayHomeAsUpEnabled(true)
setDisplayShowHomeEnabled(true)
}
setTitle(R.string.debug_frost)
@Inject
lateinit var activityThemer: ActivityThemer
activityThemer.setFrostColors { toolbar(toolbar) }
debugWebview.loadUrl(FbItem.FEED.url)
debugWebview.onPageFinished = { swipeRefresh.isRefreshing = false }
@Inject
lateinit var themeProvider: ThemeProvider
swipeRefresh.setOnRefreshListener(debugWebview::reload)
lateinit var binding: ActivityDebugBinding
fab.visible().setIcon(GoogleMaterial.Icon.gmd_bug_report, themeProvider.iconColor)
fab.backgroundTintList = ColorStateList.valueOf(themeProvider.accentColor)
fab.setOnClickListener { _ ->
fab.hide()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityDebugBinding.inflate(layoutInflater)
setContentView(binding.root)
binding.init()
}
fun ActivityDebugBinding.init() {
setSupportActionBar(toolbar)
supportActionBar?.apply {
setDisplayHomeAsUpEnabled(true)
setDisplayShowHomeEnabled(true)
}
setTitle(R.string.debug_frost)
activityThemer.setFrostColors {
toolbar(toolbar)
}
debugWebview.loadUrl(FbItem.FEED.url)
debugWebview.onPageFinished = { swipeRefresh.isRefreshing = false }
swipeRefresh.setOnRefreshListener(debugWebview::reload)
fab.visible().setIcon(GoogleMaterial.Icon.gmd_bug_report, themeProvider.iconColor)
fab.backgroundTintList = ColorStateList.valueOf(themeProvider.accentColor)
fab.setOnClickListener { _ ->
fab.hide()
val errorHandler = CoroutineExceptionHandler { _, throwable ->
L.e { "DebugActivity error ${throwable.message}" }
setResult(Activity.RESULT_CANCELED)
finish()
}
launchMain(errorHandler) {
val parent = baseDir(this@DebugActivity)
parent.createFreshDir()
val body: String? = suspendCoroutine { cont ->
debugWebview.evaluateJavascript(JsActions.RETURN_BODY.function) {
cont.resume(it)
}
}
val hasScreenshot: Boolean =
debugWebview.getScreenshot(File(parent, "screenshot.png"))
val intent = Intent()
intent.putExtra(RESULT_URL, debugWebview.url)
intent.putExtra(RESULT_SCREENSHOT, hasScreenshot)
if (body != null)
intent.putExtra(RESULT_BODY, body)
setResult(Activity.RESULT_OK, intent)
finish()
}
}
}
override fun onSupportNavigateUp(): Boolean {
val errorHandler = CoroutineExceptionHandler { _, throwable ->
L.e { "DebugActivity error ${throwable.message}" }
setResult(Activity.RESULT_CANCELED)
finish()
return true
}
}
override fun onResume() {
super.onResume()
binding.debugWebview.resumeTimers()
}
launchMain(errorHandler) {
val parent = baseDir(this@DebugActivity)
parent.createFreshDir()
override fun onPause() {
binding.debugWebview.pauseTimers()
super.onPause()
}
val body: String? = suspendCoroutine { cont ->
debugWebview.evaluateJavascript(JsActions.RETURN_BODY.function) { cont.resume(it) }
}
override fun onBackPressed() {
if (binding.debugWebview.canGoBack())
binding.debugWebview.goBack()
else
super.onBackPressed()
val hasScreenshot: Boolean = debugWebview.getScreenshot(File(parent, "screenshot.png"))
val intent = Intent()
intent.putExtra(RESULT_URL, debugWebview.url)
intent.putExtra(RESULT_SCREENSHOT, hasScreenshot)
if (body != null) intent.putExtra(RESULT_BODY, body)
setResult(Activity.RESULT_OK, intent)
finish()
}
}
}
override fun onSupportNavigateUp(): Boolean {
finish()
return true
}
override fun onResume() {
super.onResume()
binding.debugWebview.resumeTimers()
}
override fun onPause() {
binding.debugWebview.pauseTimers()
super.onPause()
}
override fun onBackPressed() {
if (binding.debugWebview.canGoBack()) binding.debugWebview.goBack() else super.onBackPressed()
}
}

View File

@ -62,323 +62,309 @@ import com.pitchedapps.frost.utils.frostUriFromFile
import com.pitchedapps.frost.utils.isIndirectImageUrl
import com.pitchedapps.frost.utils.logFrostEvent
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.launch
import java.io.File
import java.io.FileNotFoundException
import javax.inject.Inject
import kotlin.math.abs
import kotlin.math.max
import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.launch
/**
* Created by Allan Wang on 2017-07-15.
*/
/** Created by Allan Wang on 2017-07-15. */
@AndroidEntryPoint
class ImageActivity : KauBaseActivity() {
@Inject
lateinit var activityThemer: ActivityThemer
@Inject lateinit var activityThemer: ActivityThemer
@Inject
lateinit var prefs: Prefs
@Inject lateinit var prefs: Prefs
@Inject
lateinit var themeProvider: ThemeProvider
@Inject lateinit var themeProvider: ThemeProvider
@Volatile
internal var errorRef: Throwable? = null
@Volatile internal var errorRef: Throwable? = null
/**
* Reference to the temporary file path
*/
internal val tempFile: File? get() = binding.imagePhoto.currentImageFile
/** Reference to the temporary file path */
internal val tempFile: File?
get() = binding.imagePhoto.currentImageFile
private lateinit var dragHelper: ViewDragHelper
private lateinit var dragHelper: ViewDragHelper
private val cookie: String? by lazy { intent.getStringExtra(ARG_COOKIE) }
private val cookie: String? by lazy { intent.getStringExtra(ARG_COOKIE) }
val imageUrl: String by lazy { intent.getStringExtra(ARG_IMAGE_URL)?.trim('"') ?: "" }
val imageUrl: String by lazy { intent.getStringExtra(ARG_IMAGE_URL)?.trim('"') ?: "" }
private lateinit var trueImageUrl: Deferred<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
private var bottomBehavior: BottomSheetBehavior<View>? = null
lateinit var binding: ActivityImageBinding
private var bottomBehavior: BottomSheetBehavior<View>? = null
private val baseBackgroundColor: Int
get() = if (prefs.blackMediaBg) Color.BLACK
else themeProvider.bgColor.withMinAlpha(235)
private val baseBackgroundColor: Int
get() = if (prefs.blackMediaBg) Color.BLACK else themeProvider.bgColor.withMinAlpha(235)
private fun loadError(e: Throwable) {
if (e.message?.contains("<!DOCTYPE html>") == true) {
applicationContext.toast(R.string.image_not_found)
private fun loadError(e: Throwable) {
if (e.message?.contains("<!DOCTYPE html>") == true) {
applicationContext.toast(R.string.image_not_found)
finish()
return
}
errorRef = e
e.logFrostEvent("Image load error")
with(binding) { if (imageProgress.isVisible) imageProgress.fadeOut() }
tempFile?.delete()
binding.error.fadeIn()
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
if (imageUrl.isEmpty()) {
return finish()
}
L.i { "Displaying image" }
trueImageUrl =
async(Dispatchers.IO) {
val result =
if (!imageUrl.isIndirectImageUrl) imageUrl
else cookie?.getFullSizedImageUrl(imageUrl) ?: imageUrl
if (result != imageUrl) L.v { "Launching image with true url $result" }
else L.v { "Launching image with url $result" }
result
}
binding = ActivityImageBinding.inflate(layoutInflater)
setContentView(binding.root)
binding.init()
launch(CoroutineExceptionHandler { _, throwable -> loadError(throwable) }) {
binding.showImage(trueImageUrl.await())
}
}
private fun ActivityImageBinding.showImage(url: String) {
imagePhoto.showImage(Uri.parse(url))
imagePhoto.setImageShownCallback(
object : ImageShownCallback {
override fun onThumbnailShown() {}
override fun onMainImageShown() {
imageProgress.fadeOut()
imagePhoto.animate().alpha(1f).scaleXY(1f).start()
}
}
)
}
private fun ActivityImageBinding.init() {
imageContainer.setBackgroundColor(baseBackgroundColor)
toolbar.setBackgroundColor(baseBackgroundColor)
this@ImageActivity.imageText.also { text ->
if (text.isNullOrBlank()) {
imageText.gone()
} else {
imageText.setTextColor(if (prefs.blackMediaBg) Color.WHITE else themeProvider.textColor)
imageText.setBackgroundColor(baseBackgroundColor.colorToForeground(0.2f).withAlpha(255))
imageText.text = text
bottomBehavior =
BottomSheetBehavior.from<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()
return
overridePendingTransition(0, 0)
}
}
errorRef = e
e.logFrostEvent("Image load error")
with(binding) {
if (imageProgress.isVisible)
imageProgress.fadeOut()
}
tempFile?.delete()
binding.error.fadeIn()
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
if (imageUrl.isEmpty()) {
return finish()
}
L.i { "Displaying image" }
trueImageUrl = async(Dispatchers.IO) {
val result = if (!imageUrl.isIndirectImageUrl) imageUrl
else cookie?.getFullSizedImageUrl(imageUrl) ?: imageUrl
if (result != imageUrl)
L.v { "Launching image with true url $result" }
else
L.v { "Launching image with url $result" }
result
}
binding = ActivityImageBinding.inflate(layoutInflater)
setContentView(binding.root)
binding.init()
launch(CoroutineExceptionHandler { _, throwable -> loadError(throwable) }) {
binding.showImage(trueImageUrl.await())
override fun onViewReleased(releasedChild: View, xvel: Float, yvel: Float) {
val overScrolled = scrollPercent > scrollThreshold
val maxOffset = releasedChild.height + 10
val finalTop =
when {
scrollToTop && (overScrolled || yvel < -dragHelper.minVelocity) -> -maxOffset
!scrollToTop && (overScrolled || yvel > dragHelper.minVelocity) -> maxOffset
else -> 0
}
dragHelper.settleCapturedViewAt(0, finalTop)
binding.imageDrag.invalidate()
}
private fun ActivityImageBinding.showImage(url: String) {
imagePhoto.showImage(Uri.parse(url))
imagePhoto.setImageShownCallback(object : ImageShownCallback {
override fun onThumbnailShown() {}
override fun clampViewPositionHorizontal(child: View, left: Int, dx: Int): Int = 0
override fun onMainImageShown() {
imageProgress.fadeOut()
imagePhoto.animate().alpha(1f).scaleXY(1f).start()
}
})
}
override fun clampViewPositionVertical(child: View, top: Int, dy: Int): Int = top
}
private fun ActivityImageBinding.init() {
imageContainer.setBackgroundColor(baseBackgroundColor)
toolbar.setBackgroundColor(baseBackgroundColor)
this@ImageActivity.imageText.also { text ->
if (text.isNullOrBlank()) {
imageText.gone()
} else {
imageText.setTextColor(if (prefs.blackMediaBg) Color.WHITE else themeProvider.textColor)
imageText.setBackgroundColor(
baseBackgroundColor.colorToForeground(0.2f).withAlpha(255)
)
imageText.text = text
bottomBehavior = BottomSheetBehavior.from<View>(imageText).apply {
addBottomSheetCallback(object : BottomSheetBehavior.BottomSheetCallback() {
override fun onSlide(bottomSheet: View, slideOffset: Float) {
imageText.alpha = slideOffset / 2 + 0.5f
}
internal suspend fun saveImage() {
frostDownload(cookie = cookie, url = trueImageUrl.await())
}
override fun onStateChanged(bottomSheet: View, newState: Int) {
// No op
}
})
}
imageText.bringToFront()
}
}
val foregroundTint = if (prefs.blackMediaBg) Color.WHITE else themeProvider.accentColor
fun ImageView.setState(state: FabStates) {
setIcon(state.iicon, color = foregroundTint, sizeDp = 24)
setOnClickListener { state.onClick(this@ImageActivity) }
}
imageProgress.tint(foregroundTint)
error.apply {
invisible()
setState(FabStates.ERROR)
}
download.apply {
setState(FabStates.DOWNLOAD)
}
share.apply {
setState(FabStates.SHARE)
}
imagePhoto.setImageLoaderCallback(object : ImageLoader.Callback {
override fun onCacheHit(imageType: Int, image: File?) {}
override fun onCacheMiss(imageType: Int, image: File?) {}
override fun onStart() {}
override fun onProgress(progress: Int) {}
override fun onFinish() {}
override fun onSuccess(image: File) {}
override fun onFail(error: Exception) {
loadError(error)
}
})
activityThemer.setFrostColors {
themeWindow = false
}
dragHelper = ViewDragHelper.create(imageDrag, ViewDragCallback()).apply {
setEdgeTrackingEnabled(ViewDragHelper.EDGE_TOP or ViewDragHelper.EDGE_BOTTOM)
}
imageDrag.dragHelper = dragHelper
imageDrag.viewToIgnore = imageText
}
private inner class ViewDragCallback : ViewDragHelper.Callback() {
private var scrollPercent: Float = 0f
private var scrollThreshold = 0.5f
private var scrollToTop = false
override fun tryCaptureView(view: View, i: Int): Boolean {
L.d { "Try capture ${view.id} $i ${binding.imagePhoto.id} ${binding.imageText.id}" }
return view === binding.imagePhoto
}
override fun getViewHorizontalDragRange(child: View): Int = 0
override fun getViewVerticalDragRange(child: View): Int = child.height
override fun onViewPositionChanged(
changedView: View,
left: Int,
top: Int,
dx: Int,
dy: Int
) {
super.onViewPositionChanged(changedView, left, top, dx, dy)
with(binding) {
// make sure that we are using the proper axis
scrollPercent = abs(top.toFloat() / imageContainer.height)
scrollToTop = top < 0
val multiplier = max(1f - scrollPercent, 0f)
toolbar.alpha = multiplier
bottomBehavior?.also {
imageText.alpha =
multiplier * (if (it.state == BottomSheetBehavior.STATE_COLLAPSED) 0.5f else 1f)
}
imageContainer.setBackgroundColor(baseBackgroundColor.adjustAlpha(multiplier))
if (scrollPercent >= 1) {
if (!isFinishing) {
finish()
overridePendingTransition(0, 0)
}
}
}
}
override fun onViewReleased(releasedChild: View, xvel: Float, yvel: Float) {
val overScrolled = scrollPercent > scrollThreshold
val maxOffset = releasedChild.height + 10
val finalTop = when {
scrollToTop && (overScrolled || yvel < -dragHelper.minVelocity) -> -maxOffset
!scrollToTop && (overScrolled || yvel > dragHelper.minVelocity) -> maxOffset
else -> 0
}
dragHelper.settleCapturedViewAt(0, finalTop)
binding.imageDrag.invalidate()
}
override fun clampViewPositionHorizontal(child: View, left: Int, dx: Int): Int = 0
override fun clampViewPositionVertical(child: View, top: Int, dy: Int): Int = top
}
internal suspend fun saveImage() {
frostDownload(cookie = cookie, url = trueImageUrl.await())
}
companion object {
private val L = KauLoggerExtension("Image", com.pitchedapps.frost.utils.L)
}
companion object {
private val L = KauLoggerExtension("Image", com.pitchedapps.frost.utils.L)
}
}
internal enum class FabStates(
val iicon: IIcon,
val iconColorProvider: (ThemeProvider) -> Int = { it.iconColor },
val backgroundTint: Int = Int.MAX_VALUE
val iicon: IIcon,
val iconColorProvider: (ThemeProvider) -> Int = { it.iconColor },
val backgroundTint: Int = Int.MAX_VALUE
) {
ERROR(GoogleMaterial.Icon.gmd_error, { Color.WHITE }, Color.RED) {
override fun onClick(activity: ImageActivity) {
val err =
activity.errorRef?.takeIf { it !is FileNotFoundException && it.message != "Image failed to decode using JPEG decoder" }
?: return
activity.materialDialog {
title(R.string.kau_error)
message(text = err.message ?: err.javaClass.name)
}
}
},
NOTHING(GoogleMaterial.Icon.gmd_adjust) {
override fun onClick(activity: ImageActivity) {}
},
DOWNLOAD(GoogleMaterial.Icon.gmd_file_download) {
override fun onClick(activity: ImageActivity) {
activity.launch {
activity.binding.download.fadeOut()
activity.saveImage()
}
}
},
SHARE(GoogleMaterial.Icon.gmd_share) {
override fun onClick(activity: ImageActivity) {
val file = activity.tempFile ?: return
try {
val photoURI = activity.frostUriFromFile(file)
val intent = Intent(Intent.ACTION_SEND).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK
putExtra(Intent.EXTRA_STREAM, photoURI)
type = "image/png"
}
activity.startActivity(intent)
} catch (e: Exception) {
activity.errorRef = e
e.logFrostEvent("Image share failed")
activity.frostSnackbar(R.string.image_share_failed, activity.themeProvider)
}
}
};
/**
* Change the fab look
* If it's in view, give it some animations
*
* TODO investigate what is wrong with fadeScaleTransition
*
* https://github.com/AllanWang/KAU/issues/184
*
*/
fun update(fab: FloatingActionButton, themeProvider: ThemeProvider) {
val tint =
if (backgroundTint != Int.MAX_VALUE) backgroundTint else themeProvider.accentColor
val iconColor = iconColorProvider(themeProvider)
if (fab.isHidden) {
fab.setIcon(iicon, color = iconColor)
fab.backgroundTintList = ColorStateList.valueOf(tint)
fab.show()
} else {
fab.hide(object : FloatingActionButton.OnVisibilityChangedListener() {
override fun onHidden(fab: FloatingActionButton) {
fab.setIcon(iicon, color = iconColor)
fab.show()
}
})
ERROR(GoogleMaterial.Icon.gmd_error, { Color.WHITE }, Color.RED) {
override fun onClick(activity: ImageActivity) {
val err =
activity.errorRef?.takeIf {
it !is FileNotFoundException && it.message != "Image failed to decode using JPEG decoder"
}
?: return
activity.materialDialog {
title(R.string.kau_error)
message(text = err.message ?: err.javaClass.name)
}
}
},
NOTHING(GoogleMaterial.Icon.gmd_adjust) {
override fun onClick(activity: ImageActivity) {}
},
DOWNLOAD(GoogleMaterial.Icon.gmd_file_download) {
override fun onClick(activity: ImageActivity) {
activity.launch {
activity.binding.download.fadeOut()
activity.saveImage()
}
}
},
SHARE(GoogleMaterial.Icon.gmd_share) {
override fun onClick(activity: ImageActivity) {
val file = activity.tempFile ?: return
try {
val photoURI = activity.frostUriFromFile(file)
val intent =
Intent(Intent.ACTION_SEND).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK
putExtra(Intent.EXTRA_STREAM, photoURI)
type = "image/png"
}
activity.startActivity(intent)
} catch (e: Exception) {
activity.errorRef = e
e.logFrostEvent("Image share failed")
activity.frostSnackbar(R.string.image_share_failed, activity.themeProvider)
}
}
};
abstract fun onClick(activity: ImageActivity)
/**
* Change the fab look If it's in view, give it some animations
*
* TODO investigate what is wrong with fadeScaleTransition
*
* https://github.com/AllanWang/KAU/issues/184
*/
fun update(fab: FloatingActionButton, themeProvider: ThemeProvider) {
val tint = if (backgroundTint != Int.MAX_VALUE) backgroundTint else themeProvider.accentColor
val iconColor = iconColorProvider(themeProvider)
if (fab.isHidden) {
fab.setIcon(iicon, color = iconColor)
fab.backgroundTintList = ColorStateList.valueOf(tint)
fab.show()
} else {
fab.hide(
object : FloatingActionButton.OnVisibilityChangedListener() {
override fun onHidden(fab: FloatingActionButton) {
fab.setIcon(iicon, color = iconColor)
fab.show()
}
}
)
}
}
abstract fun onClick(activity: ImageActivity)
}

View File

@ -55,190 +55,174 @@ import com.pitchedapps.frost.utils.launchNewTask
import com.pitchedapps.frost.utils.loadAssets
import com.pitchedapps.frost.widgets.NotificationWidget
import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.launch
import javax.inject.Inject
/**
* Created by Allan Wang on 2017-07-25.
*
* A beautiful intro activity
* Phone showcases are drawn via layers
* A beautiful intro activity Phone showcases are drawn via layers
*/
@AndroidEntryPoint
class IntroActivity :
KauBaseActivity(),
ViewPager.PageTransformer,
ViewPager.OnPageChangeListener {
class IntroActivity : KauBaseActivity(), ViewPager.PageTransformer, ViewPager.OnPageChangeListener {
@Inject
lateinit var prefs: Prefs
@Inject lateinit var prefs: Prefs
@Inject
lateinit var themeProvider: ThemeProvider
@Inject lateinit var themeProvider: ThemeProvider
@Inject
lateinit var activityThemer: ActivityThemer
@Inject lateinit var activityThemer: ActivityThemer
lateinit var binding: ActivityIntroBinding
private var barHasNext = true
lateinit var binding: ActivityIntroBinding
private var barHasNext = true
private val fragments by lazyUi {
listOf(
IntroFragmentWelcome(),
IntroFragmentTheme(),
IntroAccountFragment(),
IntroTabTouchFragment(),
IntroTabContextFragment(),
IntroFragmentEnd()
)
private val fragments by lazyUi {
listOf(
IntroFragmentWelcome(),
IntroFragmentTheme(),
IntroAccountFragment(),
IntroTabTouchFragment(),
IntroTabContextFragment(),
IntroFragmentEnd()
)
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityIntroBinding.inflate(layoutInflater)
setContentView(binding.root)
binding.init()
}
private fun ActivityIntroBinding.init() {
viewpager.apply {
setPageTransformer(true, this@IntroActivity)
addOnPageChangeListener(this@IntroActivity)
adapter = IntroPageAdapter(supportFragmentManager, fragments)
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityIntroBinding.inflate(layoutInflater)
setContentView(binding.root)
binding.init()
indicator.setViewPager(viewpager)
next.setIcon(GoogleMaterial.Icon.gmd_navigate_next)
next.setOnClickListener {
if (barHasNext) viewpager.setCurrentItem(viewpager.currentItem + 1, true)
else finish(next.x + next.pivotX, next.y + next.pivotY)
}
skip.setOnClickListener { finish() }
ripple.set(themeProvider.bgColor)
theme()
}
private fun ActivityIntroBinding.init() {
viewpager.apply {
setPageTransformer(true, this@IntroActivity)
addOnPageChangeListener(this@IntroActivity)
adapter = IntroPageAdapter(supportFragmentManager, fragments)
}
indicator.setViewPager(viewpager)
next.setIcon(GoogleMaterial.Icon.gmd_navigate_next)
next.setOnClickListener {
if (barHasNext) viewpager.setCurrentItem(viewpager.currentItem + 1, true)
else finish(next.x + next.pivotX, next.y + next.pivotY)
}
skip.setOnClickListener { finish() }
ripple.set(themeProvider.bgColor)
theme()
fun theme() {
statusBarColor = themeProvider.headerColor
navigationBarColor = themeProvider.headerColor
with(binding) {
skip.setTextColor(themeProvider.textColor)
next.imageTintList = ColorStateList.valueOf(themeProvider.textColor)
indicator.setColour(themeProvider.textColor)
indicator.invalidate()
}
fragments.forEach { it.themeFragment() }
activityThemer.setFrostTheme(forceTransparent = true)
}
fun theme() {
statusBarColor = themeProvider.headerColor
navigationBarColor = themeProvider.headerColor
with(binding) {
skip.setTextColor(themeProvider.textColor)
next.imageTintList = ColorStateList.valueOf(themeProvider.textColor)
indicator.setColour(themeProvider.textColor)
indicator.invalidate()
}
fragments.forEach { it.themeFragment() }
activityThemer.setFrostTheme(forceTransparent = true)
/**
* Transformations are mainly handled on a per view basis This makes the first fragment fade out
* as the second fragment comes in All fragments are locked in position
*/
override fun transformPage(page: View, position: Float) {
// only apply to adjacent pages
if ((position < 0 && position > -1) || (position > 0 && position < 1)) {
val pageWidth = page.width
val translateValue = position * -pageWidth
page.translationX = (if (translateValue > -pageWidth) translateValue else 0f)
page.alpha = if (position < 0) 1 + position else 1f
} else {
page.alpha = 1f
page.translationX = 0f
}
}
/**
* Transformations are mainly handled on a per view basis
* This makes the first fragment fade out as the second fragment comes in
* All fragments are locked in position
*/
override fun transformPage(page: View, position: Float) {
// only apply to adjacent pages
if ((position < 0 && position > -1) || (position > 0 && position < 1)) {
val pageWidth = page.width
val translateValue = position * -pageWidth
page.translationX = (if (translateValue > -pageWidth) translateValue else 0f)
page.alpha = if (position < 0) 1 + position else 1f
} else {
page.alpha = 1f
page.translationX = 0f
fun finish(x: Float, y: Float) {
val blue = color(R.color.facebook_blue)
window.setFlags(
WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE,
WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE
)
binding.ripple.ripple(blue, x, y, 600) { postDelayed(1000) { finish() } }
val lastView: View? = fragments.last().view
arrayOf<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()
}
}
fun finish(x: Float, y: Float) {
val blue = color(R.color.facebook_blue)
window.setFlags(
WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE,
WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE
)
binding.ripple.ripple(blue, x, y, 600) {
postDelayed(1000) { finish() }
}
val lastView: View? = fragments.last().view
arrayOf<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()
}
if (themeProvider.headerColor != blue) {
ValueAnimator.ofFloat(0f, 1f).apply {
addUpdateListener {
val c = themeProvider.headerColor.blendWith(blue, it.animatedValue as Float)
statusBarColor = c
navigationBarColor = c
}
duration = 600
start()
}
}
}
override fun finish() {
launch(NonCancellable) {
loadAssets(themeProvider)
NotificationWidget.forceUpdate(this@IntroActivity)
launchNewTask<MainActivity>(cookies(), false)
super.finish()
}
override fun finish() {
launch(NonCancellable) {
loadAssets(themeProvider)
NotificationWidget.forceUpdate(this@IntroActivity)
launchNewTask<MainActivity>(cookies(), false)
super.finish()
}
}
override fun onBackPressed() {
with(binding) {
if (viewpager.currentItem > 0) viewpager.setCurrentItem(viewpager.currentItem - 1, true)
else finish()
}
override fun onBackPressed() {
with(binding) {
if (viewpager.currentItem > 0) viewpager.setCurrentItem(viewpager.currentItem - 1, true)
else finish()
}
}
override fun onPageScrollStateChanged(state: Int) {
override fun onPageScrollStateChanged(state: Int) {}
override fun onPageScrolled(position: Int, positionOffset: Float, positionOffsetPixels: Int) {
fragments[position].onPageScrolled(positionOffset)
if (position + 1 < fragments.size) fragments[position + 1].onPageScrolled(positionOffset - 1)
}
override fun onPageSelected(position: Int) {
fragments[position].onPageSelected()
val hasNext = position != fragments.size - 1
if (barHasNext == hasNext) return
barHasNext = hasNext
binding.next.fadeScaleTransition {
setIcon(
if (barHasNext) GoogleMaterial.Icon.gmd_navigate_next else GoogleMaterial.Icon.gmd_done,
color = themeProvider.textColor
)
}
binding.skip.animate().scaleXY(if (barHasNext) 1f else 0f)
}
override fun onPageScrolled(position: Int, positionOffset: Float, positionOffsetPixels: Int) {
fragments[position].onPageScrolled(positionOffset)
if (position + 1 < fragments.size)
fragments[position + 1].onPageScrolled(positionOffset - 1)
}
class IntroPageAdapter(fm: FragmentManager, private val fragments: List<BaseIntroFragment>) :
FragmentPagerAdapter(fm) {
override fun onPageSelected(position: Int) {
fragments[position].onPageSelected()
val hasNext = position != fragments.size - 1
if (barHasNext == hasNext) return
barHasNext = hasNext
binding.next.fadeScaleTransition {
setIcon(
if (barHasNext) GoogleMaterial.Icon.gmd_navigate_next else GoogleMaterial.Icon.gmd_done,
color = themeProvider.textColor
)
}
binding.skip.animate().scaleXY(if (barHasNext) 1f else 0f)
}
override fun getItem(position: Int): Fragment = fragments[position]
class IntroPageAdapter(fm: FragmentManager, private val fragments: List<BaseIntroFragment>) :
FragmentPagerAdapter(fm) {
override fun getItem(position: Int): Fragment = fragments[position]
override fun getCount(): Int = fragments.size
}
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.asFrostEmitter
import dagger.hilt.android.AndroidEntryPoint
import java.net.UnknownHostException
import javax.inject.Inject
import kotlin.coroutines.resume
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.channels.BufferOverflow
@ -63,165 +66,157 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeout
import java.net.UnknownHostException
import javax.inject.Inject
import kotlin.coroutines.resume
/**
* Created by Allan Wang on 2017-06-01.
*/
/** Created by Allan Wang on 2017-06-01. */
@AndroidEntryPoint
class LoginActivity : BaseActivity() {
@Inject
lateinit var cookieDao: CookieDao
@Inject lateinit var cookieDao: CookieDao
private val toolbar: Toolbar by bindView(R.id.toolbar)
private val web: LoginWebView by bindView(R.id.login_webview)
private val swipeRefresh: SwipeRefreshLayout by bindView(R.id.swipe_refresh)
private val textview: AppCompatTextView by bindView(R.id.textview)
private val profile: ImageView by bindView(R.id.profile)
private val toolbar: Toolbar by bindView(R.id.toolbar)
private val web: LoginWebView by bindView(R.id.login_webview)
private val swipeRefresh: SwipeRefreshLayout by bindView(R.id.swipe_refresh)
private val textview: AppCompatTextView by bindView(R.id.textview)
private val profile: ImageView by bindView(R.id.profile)
private lateinit var profileLoader: RequestManager
private lateinit var profileLoader: RequestManager
private val refreshMutableFlow = MutableSharedFlow<Boolean>(
extraBufferCapacity = 10,
onBufferOverflow = BufferOverflow.DROP_OLDEST
private val refreshMutableFlow =
MutableSharedFlow<Boolean>(
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?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_login)
setSupportActionBar(toolbar)
setTitle(R.string.kau_login)
activityThemer.setFrostColors {
toolbar(toolbar)
}
profileLoader = GlideApp.with(profile)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_login)
setSupportActionBar(toolbar)
setTitle(R.string.kau_login)
activityThemer.setFrostColors { toolbar(toolbar) }
profileLoader = GlideApp.with(profile)
refreshFlow
.distinctUntilChanged()
.onEach { swipeRefresh.isRefreshing = it }
.launchIn(this)
refreshFlow.distinctUntilChanged().onEach { swipeRefresh.isRefreshing = it }.launchIn(this)
launch {
val cookie = web.loadLogin { refresh(it != 100) }.await()
L.d { "Login found" }
fbCookie.save(cookie.id)
webFadeOut()
profile.fadeIn()
loadInfo(cookie)
}
launch {
val cookie = web.loadLogin { refresh(it != 100) }.await()
L.d { "Login found" }
fbCookie.save(cookie.id)
webFadeOut()
profile.fadeIn()
loadInfo(cookie)
}
}
private suspend fun webFadeOut(): Unit = suspendCancellableCoroutine { cont ->
web.fadeOut { cont.resume(Unit) }
}
private fun refresh(refreshing: Boolean) {
refreshEmit(refreshing)
}
private suspend fun loadInfo(cookie: CookieEntity): Unit = withMainContext {
refresh(true)
val imageDeferred = async { loadProfile(cookie.id) }
val nameDeferred = async { loadUsername(cookie) }
val name: String? = nameDeferred.await()
val foundImage: Boolean = imageDeferred.await()
L._d { "Logged in and received data" }
refresh(false)
if (!foundImage) {
L.e { "Could not get profile photo; Invalid userId?" }
L._i { cookie }
}
private suspend fun webFadeOut(): Unit = suspendCancellableCoroutine { cont ->
web.fadeOut { cont.resume(Unit) }
}
textview.text = String.format(getString(R.string.welcome), name ?: "")
textview.fadeIn()
frostEvent("Login", "success" to true)
private fun refresh(refreshing: Boolean) {
refreshEmit(refreshing)
}
/*
* The user may have logged into an account that is already in the database
* We will let the db handle duplicates and load it now after the new account has been saved
*/
val cookies = ArrayList(cookieDao.selectAll())
delay(1000)
if (prefs.intro) launchNewTask<IntroActivity>(cookies, true)
else launchNewTask<MainActivity>(cookies, true)
}
private suspend fun loadInfo(cookie: CookieEntity): Unit = withMainContext {
refresh(true)
val imageDeferred = async { loadProfile(cookie.id) }
val nameDeferred = async { loadUsername(cookie) }
val name: String? = nameDeferred.await()
val foundImage: Boolean = imageDeferred.await()
L._d { "Logged in and received data" }
refresh(false)
if (!foundImage) {
L.e { "Could not get profile photo; Invalid userId?" }
L._i { cookie }
}
textview.text = String.format(getString(R.string.welcome), name ?: "")
textview.fadeIn()
frostEvent("Login", "success" to true)
/*
* The user may have logged into an account that is already in the database
* We will let the db handle duplicates and load it now after the new account has been saved
*/
val cookies = ArrayList(cookieDao.selectAll())
delay(1000)
if (prefs.intro)
launchNewTask<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()
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() }
} catch (e: Exception) {
if (e !is UnknownHostException)
e.logFrostEvent("Fetch username failed")
null
if (e !is UnknownHostException) e.logFrostEvent("Fetch username failed")
null
}
if (result != null) {
cookieDao.save(cookie.copy(name = result))
return@withContext result
}
if (result != null) {
cookieDao.save(cookie.copy(name = result))
return@withContext result
}
return@withContext cookie.name
return@withContext cookie.name
}
override fun backConsumer(): Boolean {
if (web.canGoBack()) {
web.goBack()
return true
}
return false
override fun backConsumer(): Boolean {
if (web.canGoBack()) {
web.goBack()
return true
}
return false
}
override fun onResume() {
super.onResume()
web.resumeTimers()
}
override fun onResume() {
super.onResume()
web.resumeTimers()
}
override fun onPause() {
web.pauseTimers()
super.onPause()
}
override fun onPause() {
web.pauseTimers()
super.onPause()
}
}

View File

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

View File

@ -32,43 +32,44 @@ import com.pitchedapps.frost.utils.launchNewTask
import com.pitchedapps.frost.views.AccountItem
import kotlinx.coroutines.launch
/**
* Created by Allan Wang on 2017-06-04.
*/
/** Created by Allan Wang on 2017-06-04. */
class SelectorActivity : BaseActivity() {
val recycler: RecyclerView by bindView(R.id.selector_recycler)
val adapter = FastItemAdapter<AccountItem>()
val text: AppCompatTextView by bindView(R.id.text_select_account)
val container: ConstraintLayout by bindView(R.id.container)
val recycler: RecyclerView by bindView(R.id.selector_recycler)
val adapter = FastItemAdapter<AccountItem>()
val text: AppCompatTextView by bindView(R.id.text_select_account)
val container: ConstraintLayout by bindView(R.id.container)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_selector)
recycler.layoutManager = GridLayoutManager(this, 2)
recycler.adapter = adapter
adapter.add(cookies().map { AccountItem(it, themeProvider) })
adapter.add(AccountItem(null, themeProvider)) // add account
adapter.addEventHook(object : ClickEventHook<AccountItem>() {
override fun onBind(viewHolder: RecyclerView.ViewHolder): View? =
(viewHolder as? AccountItem.ViewHolder)?.itemView
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_selector)
recycler.layoutManager = GridLayoutManager(this, 2)
recycler.adapter = adapter
adapter.add(cookies().map { AccountItem(it, themeProvider) })
adapter.add(AccountItem(null, themeProvider)) // add account
adapter.addEventHook(
object : ClickEventHook<AccountItem>() {
override fun onBind(viewHolder: RecyclerView.ViewHolder): View? =
(viewHolder as? AccountItem.ViewHolder)?.itemView
override fun onClick(
v: View,
position: Int,
fastAdapter: FastAdapter<AccountItem>,
item: AccountItem
) {
if (item.cookie == null) this@SelectorActivity.launchNewTask<LoginActivity>()
else launch {
fbCookie.switchUser(item.cookie)
launchNewTask<MainActivity>(cookies())
}
override fun onClick(
v: View,
position: Int,
fastAdapter: FastAdapter<AccountItem>,
item: AccountItem
) {
if (item.cookie == null) this@SelectorActivity.launchNewTask<LoginActivity>()
else
launch {
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.loadAssets
import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.launch
import javax.inject.Inject
/**
* Created by Allan Wang on 2017-06-06.
*/
/** Created by Allan Wang on 2017-06-06. */
@AndroidEntryPoint
class SettingsActivity : KPrefActivity() {
@Inject
lateinit var fbCookie: FbCookie
@Inject lateinit var fbCookie: FbCookie
@Inject
lateinit var prefs: Prefs
@Inject lateinit var prefs: Prefs
@Inject
lateinit var themeProvider: ThemeProvider
@Inject lateinit var themeProvider: ThemeProvider
@Inject
lateinit var notifDao: NotificationDao
@Inject lateinit var notifDao: NotificationDao
@Inject
lateinit var activityThemer: ActivityThemer
@Inject lateinit var activityThemer: ActivityThemer
private var resultFlag = Activity.RESULT_CANCELED
private var resultFlag = Activity.RESULT_CANCELED
companion object {
private const val REQUEST_RINGTONE = 0b10111 shl 5
const val REQUEST_NOTIFICATION_RINGTONE = REQUEST_RINGTONE or 1
const val REQUEST_MESSAGE_RINGTONE = REQUEST_RINGTONE or 2
const val ACTIVITY_REQUEST_TABS = 29
const val ACTIVITY_REQUEST_DEBUG = 53
companion object {
private const val REQUEST_RINGTONE = 0b10111 shl 5
const val REQUEST_NOTIFICATION_RINGTONE = REQUEST_RINGTONE or 1
const val REQUEST_MESSAGE_RINGTONE = REQUEST_RINGTONE or 2
const val ACTIVITY_REQUEST_TABS = 29
const val ACTIVITY_REQUEST_DEBUG = 53
}
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
super.onActivityResult(requestCode, resultCode, data)
if (fetchRingtone(requestCode, resultCode, data)) return
when (requestCode) {
ACTIVITY_REQUEST_TABS -> {
if (resultCode == Activity.RESULT_OK) shouldRestartMain()
return
}
ACTIVITY_REQUEST_DEBUG -> {
val url = data?.extras?.getString(DebugActivity.RESULT_URL)
if (resultCode == Activity.RESULT_OK && url?.isNotBlank() == true)
sendDebug(url, data.getStringExtra(DebugActivity.RESULT_BODY))
return
}
}
reloadList()
}
/** Fetch ringtone and save uri Returns [true] if consumed, [false] otherwise */
private fun fetchRingtone(requestCode: Int, resultCode: Int, data: Intent?): Boolean {
if (requestCode and REQUEST_RINGTONE != REQUEST_RINGTONE || resultCode != Activity.RESULT_OK)
return false
val uri = data?.getParcelableExtra<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?) {
super.onActivityResult(requestCode, resultCode, data)
if (fetchRingtone(requestCode, resultCode, data)) return
when (requestCode) {
ACTIVITY_REQUEST_TABS -> {
if (resultCode == Activity.RESULT_OK)
shouldRestartMain()
return
}
ACTIVITY_REQUEST_DEBUG -> {
val url = data?.extras?.getString(DebugActivity.RESULT_URL)
if (resultCode == Activity.RESULT_OK && url?.isNotBlank() == true)
sendDebug(url, data.getStringExtra(DebugActivity.RESULT_BODY))
return
}
}
reloadList()
subItems(R.string.behaviour, getBehaviourPrefs()) {
descRes = R.string.behaviour_desc
iicon = GoogleMaterial.Icon.gmd_trending_up
}
/**
* Fetch ringtone and save uri
* Returns [true] if consumed, [false] otherwise
*/
private fun fetchRingtone(requestCode: Int, resultCode: Int, data: Intent?): Boolean {
if (requestCode and REQUEST_RINGTONE != REQUEST_RINGTONE || resultCode != Activity.RESULT_OK) return false
val uri = data?.getParcelableExtra<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
subItems(R.string.newsfeed, getFeedPrefs()) {
descRes = R.string.newsfeed_desc
iicon = CommunityMaterial.Icon3.cmd_newspaper
}
override fun kPrefCoreAttributes(): CoreAttributeContract.() -> Unit = {
textColor = { themeProvider.textColor }
accentColor = { themeProvider.accentColor }
subItems(R.string.notifications, getNotificationPrefs()) {
descRes = R.string.notifications_desc
iicon = GoogleMaterial.Icon.gmd_notifications
}
override fun onCreateKPrefs(savedInstanceState: Bundle?): KPrefAdapterBuilder.() -> Unit = {
subItems(R.string.appearance, getAppearancePrefs()) {
descRes = R.string.appearance_desc
iicon = GoogleMaterial.Icon.gmd_palette
}
subItems(R.string.behaviour, getBehaviourPrefs()) {
descRes = R.string.behaviour_desc
iicon = GoogleMaterial.Icon.gmd_trending_up
}
subItems(R.string.newsfeed, getFeedPrefs()) {
descRes = R.string.newsfeed_desc
iicon = CommunityMaterial.Icon3.cmd_newspaper
}
subItems(R.string.notifications, getNotificationPrefs()) {
descRes = R.string.notifications_desc
iicon = GoogleMaterial.Icon.gmd_notifications
}
subItems(R.string.security, getSecurityPrefs()) {
descRes = R.string.security_desc
iicon = GoogleMaterial.Icon.gmd_lock
}
// subItems(R.string.network, getNetworkPrefs()) {
// descRes = R.string.network_desc
// iicon = GoogleMaterial.Icon.gmd_network_cell
// }
// todo add donation?
plainText(R.string.about_frost) {
descRes = R.string.about_frost_desc
iicon = GoogleMaterial.Icon.gmd_info
onClick = {
startActivityForResult<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 }
}
subItems(R.string.security, getSecurityPrefs()) {
descRes = R.string.security_desc
iicon = GoogleMaterial.Icon.gmd_lock
}
fun setFrostResult(flag: Int) {
resultFlag = resultFlag or flag
}
// subItems(R.string.network, getNetworkPrefs()) {
// descRes = R.string.network_desc
// iicon = GoogleMaterial.Icon.gmd_network_cell
// }
fun shouldRestartMain() {
setFrostResult(REQUEST_RESTART)
}
// todo add donation?
fun shouldRefreshMain() {
setFrostResult(REQUEST_REFRESH)
}
@SuppressLint("MissingSuperCall")
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
activityThemer.setFrostTheme(forceTransparent = true)
animate = prefs.animate
themeExterior(false)
}
fun themeExterior(animate: Boolean = true) {
if (animate) bgCanvas.fade(themeProvider.bgColor)
else bgCanvas.set(themeProvider.bgColor)
if (animate) toolbarCanvas.ripple(
themeProvider.headerColor,
RippleCanvas.MIDDLE,
RippleCanvas.END
plainText(R.string.about_frost) {
descRes = R.string.about_frost_desc
iicon = GoogleMaterial.Icon.gmd_info
onClick = {
startActivityForResult<AboutActivity>(
9,
bundleBuilder = { withSceneTransitionAnimation(this@SettingsActivity) }
)
else toolbarCanvas.set(themeProvider.headerColor)
frostNavigationBar(prefs, themeProvider)
}
}
override fun onBackPressed() {
if (!super.backPress()) {
setResult(resultFlag)
launch(NonCancellable) {
loadAssets(themeProvider)
finishSlideOut()
}
}
plainText(R.string.help_translate) {
descRes = R.string.help_translate_desc
iicon = GoogleMaterial.Icon.gmd_translate
onClick = { startLink(R.string.translation_url) }
}
override fun onCreateOptionsMenu(menu: Menu): Boolean {
menuInflater.inflate(R.menu.menu_settings, menu)
toolbar.tint(themeProvider.iconColor)
setMenuIcons(
menu, themeProvider.iconColor,
R.id.action_github to CommunityMaterial.Icon2.cmd_github,
R.id.action_changelog to GoogleMaterial.Icon.gmd_info
)
return true
plainText(R.string.replay_intro) {
iicon = GoogleMaterial.Icon.gmd_replay
onClick = { launchNewTask<IntroActivity>(cookies(), true) }
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
when (item.itemId) {
R.id.action_github -> startLink(R.string.github_url)
R.id.action_changelog -> frostChangelog()
else -> return super.onOptionsItemSelected(item)
}
return true
subItems(R.string.experimental, getExperimentalPrefs()) {
descRes = R.string.experimental_desc
iicon = CommunityMaterial.Icon2.cmd_flask_outline
}
subItems(R.string.debug_frost, getDebugPrefs()) {
descRes = R.string.debug_frost_desc
iicon = CommunityMaterial.Icon.cmd_bug
visible = { prefs.debugSettings }
}
}
fun setFrostResult(flag: Int) {
resultFlag = resultFlag or flag
}
fun shouldRestartMain() {
setFrostResult(REQUEST_RESTART)
}
fun shouldRefreshMain() {
setFrostResult(REQUEST_REFRESH)
}
@SuppressLint("MissingSuperCall")
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
activityThemer.setFrostTheme(forceTransparent = true)
animate = prefs.animate
themeExterior(false)
}
fun themeExterior(animate: Boolean = true) {
if (animate) bgCanvas.fade(themeProvider.bgColor) else bgCanvas.set(themeProvider.bgColor)
if (animate)
toolbarCanvas.ripple(themeProvider.headerColor, RippleCanvas.MIDDLE, RippleCanvas.END)
else toolbarCanvas.set(themeProvider.headerColor)
frostNavigationBar(prefs, themeProvider)
}
override fun onBackPressed() {
if (!super.backPress()) {
setResult(resultFlag)
launch(NonCancellable) {
loadAssets(themeProvider)
finishSlideOut()
}
}
}
override fun onCreateOptionsMenu(menu: Menu): Boolean {
menuInflater.inflate(R.menu.menu_settings, menu)
toolbar.tint(themeProvider.iconColor)
setMenuIcons(
menu,
themeProvider.iconColor,
R.id.action_github to CommunityMaterial.Icon2.cmd_github,
R.id.action_changelog to GoogleMaterial.Icon.gmd_info
)
return true
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
when (item.itemId) {
R.id.action_github -> startLink(R.string.github_url)
R.id.action_changelog -> frostChangelog()
else -> return super.onOptionsItemSelected(item)
}
return true
}
}

View File

@ -43,119 +43,116 @@ import com.pitchedapps.frost.facebook.FbItem
import com.pitchedapps.frost.iitems.TabIItem
import com.pitchedapps.frost.utils.L
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.launch
import java.util.Collections
import javax.inject.Inject
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.launch
/**
* Created by Allan Wang on 26/11/17.
*/
/** Created by Allan Wang on 26/11/17. */
@AndroidEntryPoint
class TabCustomizerActivity : BaseActivity() {
@Inject
lateinit var genericDao: GenericDao
@Inject lateinit var genericDao: GenericDao
private val adapter = FastItemAdapter<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?) {
super.onCreate(savedInstanceState)
binding = ActivityTabCustomizerBinding.inflate(layoutInflater)
setContentView(binding.root)
binding.init()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityTabCustomizerBinding.inflate(layoutInflater)
setContentView(binding.root)
binding.init()
}
fun ActivityTabCustomizerBinding.init() {
pseudoToolbar.setBackgroundColor(themeProvider.headerColor)
tabRecycler.layoutManager =
GridLayoutManager(this@TabCustomizerActivity, TAB_COUNT, RecyclerView.VERTICAL, false)
tabRecycler.adapter = adapter
tabRecycler.setHasFixedSize(true)
divider.setBackgroundColor(themeProvider.textColor.withAlpha(30))
instructions.setTextColor(themeProvider.textColor)
launch {
val tabs = genericDao.getTabs().toMutableList()
L.d { "Tabs $tabs" }
val remaining = FbItem.values().filter { it.name[0] != '_' }.toMutableList()
remaining.removeAll(tabs)
tabs.addAll(remaining)
adapter.set(tabs.map { TabIItem(it, themeProvider) })
bindSwapper(adapter, tabRecycler)
adapter.onClickListener = { view, _, _, _ ->
view!!.wobble()
true
}
}
fun ActivityTabCustomizerBinding.init() {
pseudoToolbar.setBackgroundColor(themeProvider.headerColor)
setResult(Activity.RESULT_CANCELED)
tabRecycler.layoutManager =
GridLayoutManager(this@TabCustomizerActivity, TAB_COUNT, RecyclerView.VERTICAL, false)
tabRecycler.adapter = adapter
tabRecycler.setHasFixedSize(true)
fabSave.setIcon(GoogleMaterial.Icon.gmd_check, themeProvider.iconColor)
fabSave.backgroundTintList = ColorStateList.valueOf(themeProvider.accentColor)
fabSave.setOnClickListener {
launchMain(NonCancellable) {
val tabs = adapter.adapterItems.subList(0, TAB_COUNT).map(TabIItem::item)
genericDao.saveTabs(tabs)
setResult(Activity.RESULT_OK)
finish()
}
}
fabCancel.setIcon(GoogleMaterial.Icon.gmd_close, themeProvider.iconColor)
fabCancel.backgroundTintList = ColorStateList.valueOf(themeProvider.accentColor)
fabCancel.setOnClickListener { finish() }
activityThemer.setFrostColors { themeWindow = true }
}
divider.setBackgroundColor(themeProvider.textColor.withAlpha(30))
instructions.setTextColor(themeProvider.textColor)
private fun View.wobble() = startAnimation(wobble(context))
launch {
val tabs = genericDao.getTabs().toMutableList()
L.d { "Tabs $tabs" }
val remaining = FbItem.values().filter { it.name[0] != '_' }.toMutableList()
remaining.removeAll(tabs)
tabs.addAll(remaining)
adapter.set(tabs.map { TabIItem(it, themeProvider) })
private fun bindSwapper(adapter: FastItemAdapter<*>, recycler: RecyclerView) {
val dragCallback = TabDragCallback(SimpleDragCallback.ALL, swapper(adapter))
ItemTouchHelper(dragCallback).attachToRecyclerView(recycler)
}
bindSwapper(adapter, tabRecycler)
private fun swapper(adapter: FastItemAdapter<*>) =
object : ItemTouchCallback {
override fun itemTouchOnMove(oldPosition: Int, newPosition: Int): Boolean {
Collections.swap(adapter.adapterItems, oldPosition, newPosition)
adapter.notifyAdapterDataSetChanged()
return true
}
adapter.onClickListener = { view, _, _, _ -> view!!.wobble(); true }
}
setResult(Activity.RESULT_CANCELED)
fabSave.setIcon(GoogleMaterial.Icon.gmd_check, themeProvider.iconColor)
fabSave.backgroundTintList = ColorStateList.valueOf(themeProvider.accentColor)
fabSave.setOnClickListener {
launchMain(NonCancellable) {
val tabs = adapter.adapterItems.subList(0, TAB_COUNT).map(TabIItem::item)
genericDao.saveTabs(tabs)
setResult(Activity.RESULT_OK)
finish()
}
}
fabCancel.setIcon(GoogleMaterial.Icon.gmd_close, themeProvider.iconColor)
fabCancel.backgroundTintList = ColorStateList.valueOf(themeProvider.accentColor)
fabCancel.setOnClickListener { finish() }
activityThemer.setFrostColors {
themeWindow = true
}
override fun itemTouchDropped(oldPosition: Int, newPosition: Int) = Unit
}
private fun View.wobble() = startAnimation(wobble(context))
private class TabDragCallback(directions: Int, itemTouchCallback: ItemTouchCallback) :
SimpleDragCallback(directions, itemTouchCallback) {
private fun bindSwapper(adapter: FastItemAdapter<*>, recycler: RecyclerView) {
val dragCallback = TabDragCallback(SimpleDragCallback.ALL, swapper(adapter))
ItemTouchHelper(dragCallback).attachToRecyclerView(recycler)
}
private var draggingView: TabIItem.ViewHolder? = null
private fun swapper(adapter: FastItemAdapter<*>) = object : ItemTouchCallback {
override fun itemTouchOnMove(oldPosition: Int, newPosition: Int): Boolean {
Collections.swap(adapter.adapterItems, oldPosition, newPosition)
adapter.notifyAdapterDataSetChanged()
return true
override fun onSelectedChanged(viewHolder: RecyclerView.ViewHolder?, actionState: Int) {
super.onSelectedChanged(viewHolder, actionState)
when (actionState) {
ItemTouchHelper.ACTION_STATE_DRAG -> {
(viewHolder as? TabIItem.ViewHolder)?.apply {
draggingView = this
itemView.animate().scaleXY(1.3f)
text.animate().alpha(0f)
}
}
override fun itemTouchDropped(oldPosition: Int, newPosition: Int) = Unit
}
private class TabDragCallback(
directions: Int,
itemTouchCallback: ItemTouchCallback
) : SimpleDragCallback(directions, itemTouchCallback) {
private var draggingView: TabIItem.ViewHolder? = null
override fun onSelectedChanged(viewHolder: RecyclerView.ViewHolder?, actionState: Int) {
super.onSelectedChanged(viewHolder, actionState)
when (actionState) {
ItemTouchHelper.ACTION_STATE_DRAG -> {
(viewHolder as? TabIItem.ViewHolder)?.apply {
draggingView = this
itemView.animate().scaleXY(1.3f)
text.animate().alpha(0f)
}
}
ItemTouchHelper.ACTION_STATE_IDLE -> {
draggingView?.apply {
itemView.animate().scaleXY(1f)
text.animate().alpha(1f)
}
draggingView = null
}
}
ItemTouchHelper.ACTION_STATE_IDLE -> {
draggingView?.apply {
itemView.animate().scaleXY(1f)
text.animate().alpha(1f)
}
draggingView = null
}
}
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -22,23 +22,23 @@ import android.widget.TextView
/**
* Created by Allan Wang on 2017-11-07.
*
* Should be implemented by all views in [com.pitchedapps.frost.activities.MainActivity]
* to allow for instant view reloading
* Should be implemented by all views in [com.pitchedapps.frost.activities.MainActivity] to allow
* for instant view reloading
*/
interface FrostThemable {
/**
* Change all necessary view components to the new theme
* and call whatever other children that also implement [FrostThemable]
*/
fun reloadTheme()
/**
* Change all necessary view components to the new theme and call whatever other children that
* also implement [FrostThemable]
*/
fun reloadTheme()
fun setTextColors(color: Int, vararg textViews: TextView?) =
themeViews(color, *textViews) { setTextColor(it) }
fun setTextColors(color: Int, vararg textViews: TextView?) =
themeViews(color, *textViews) { setTextColor(it) }
fun setBackgrounds(color: Int, vararg views: View?) =
themeViews(color, *views) { setBackgroundColor(it) }
fun setBackgrounds(color: Int, vararg views: View?) =
themeViews(color, *views) { setBackgroundColor(it) }
fun <T : View> themeViews(color: Int, vararg views: T?, action: T.(Int) -> Unit) =
views.filterNotNull().forEach { it.action(color) }
fun <T : View> themeViews(color: Int, vararg views: T?, action: T.(Int) -> Unit) =
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.FrostVideoViewer
/**
* Created by Allan Wang on 2017-11-10.
*/
/** Created by Allan Wang on 2017-11-10. */
interface VideoViewHolder : FrameWrapper, FrostVideoContainerContract {
var videoViewer: FrostVideoViewer?
var videoViewer: FrostVideoViewer?
fun showVideo(url: String) = showVideo(url, false)
fun showVideo(url: String) = showVideo(url, false)
/**
* Create new viewer and reuse existing one
* The url will be formatted upon loading
*/
fun showVideo(url: String, repeat: Boolean) {
if (videoViewer != null)
videoViewer?.setVideo(url, repeat)
else
videoViewer = FrostVideoViewer.showVideo(url, repeat, this)
}
/** Create new viewer and reuse existing one The url will be formatted upon loading */
fun showVideo(url: String, repeat: Boolean) {
if (videoViewer != null) videoViewer?.setVideo(url, repeat)
else videoViewer = FrostVideoViewer.showVideo(url, repeat, this)
}
fun videoOnStop() = videoViewer?.pause()
fun videoOnStop() = videoViewer?.pause()
fun videoOnBackPress() = videoViewer?.onBackPressed() ?: false
fun videoOnBackPress() = videoViewer?.onBackPressed() ?: false
override val videoContainer: FrameLayout
get() = frameWrapper
override val videoContainer: FrameLayout
get() = frameWrapper
override fun onVideoFinished() {
L.d { "Video view released" }
videoViewer = null
}
override fun onVideoFinished() {
L.d { "Video view released" }
videoViewer = null
}
}
interface FrameWrapper {
val frameWrapper: FrameLayout
val frameWrapper: FrameLayout
fun Activity.setFrameContentView(layoutRes: Int) {
setContentView(R.layout.activity_frame_wrapper)
frameWrapper.inflate(layoutRes, true)
}
fun Activity.setFrameContentView(layoutRes: Int) {
setContentView(R.layout.activity_frame_wrapper)
frameWrapper.inflate(layoutRes, true)
}
}

View File

@ -26,63 +26,52 @@ import androidx.room.Query
import com.pitchedapps.frost.utils.L
import kotlinx.android.parcel.Parcelize
/**
* Created by Allan Wang on 2017-05-30.
*/
/** Created by Allan Wang on 2017-05-30. */
/**
* Generic cache to store serialized content
*/
/** Generic cache to store serialized content */
@Entity(
tableName = "frost_cache",
primaryKeys = ["id", "type"],
foreignKeys = [
ForeignKey(
entity = CookieEntity::class,
parentColumns = ["cookie_id"],
childColumns = ["id"],
onDelete = ForeignKey.CASCADE
)
]
tableName = "frost_cache",
primaryKeys = ["id", "type"],
foreignKeys =
[
ForeignKey(
entity = CookieEntity::class,
parentColumns = ["cookie_id"],
childColumns = ["id"],
onDelete = ForeignKey.CASCADE
)]
)
@Parcelize
data class CacheEntity(
val id: Long,
val type: String,
val lastUpdated: Long,
val contents: String
val id: Long,
val type: String,
val lastUpdated: Long,
val contents: String
) : Parcelable
@Dao
interface CacheDao {
@Query("SELECT * FROM frost_cache WHERE id = :id AND type = :type")
fun _select(id: Long, type: String): CacheEntity?
@Query("SELECT * FROM frost_cache WHERE id = :id AND type = :type")
fun _select(id: Long, type: String): CacheEntity?
@Insert(onConflict = OnConflictStrategy.REPLACE)
fun _insertCache(cache: CacheEntity)
@Insert(onConflict = OnConflictStrategy.REPLACE) fun _insertCache(cache: CacheEntity)
@Query("DELETE FROM frost_cache WHERE id = :id AND type = :type")
fun _delete(id: Long, type: String)
@Query("DELETE FROM frost_cache WHERE id = :id AND type = :type")
fun _delete(id: Long, type: String)
}
suspend fun CacheDao.select(id: Long, type: String) = dao {
_select(id, type)
}
suspend fun CacheDao.select(id: Long, type: String) = dao { _select(id, type) }
suspend fun CacheDao.delete(id: Long, type: String) = dao {
_delete(id, type)
}
suspend fun CacheDao.delete(id: Long, type: String) = dao { _delete(id, type) }
/**
* Returns true if successful, given that there are constraints to the insertion
*/
/** Returns true if successful, given that there are constraints to the insertion */
suspend fun CacheDao.save(id: Long, type: String, contents: String): Boolean = dao {
try {
_insertCache(CacheEntity(id, type, System.currentTimeMillis(), contents))
true
} catch (e: Exception) {
L.e(e) { "Cache save failed for $type" }
false
}
try {
_insertCache(CacheEntity(id, type, System.currentTimeMillis(), contents))
true
} catch (e: Exception) {
L.e(e) { "Cache save failed for $type" }
false
}
}

View File

@ -28,59 +28,57 @@ import androidx.sqlite.db.SupportSQLiteDatabase
import com.pitchedapps.frost.prefs.Prefs
import kotlinx.android.parcel.Parcelize
/**
* Created by Allan Wang on 2017-05-30.
*/
/** Created by Allan Wang on 2017-05-30. */
@Entity(tableName = "cookies")
@Parcelize
data class CookieEntity(
@androidx.room.PrimaryKey
@ColumnInfo(name = "cookie_id")
val id: Long,
val name: String?,
val cookie: String?,
val cookieMessenger: String? = null // Version 2
@androidx.room.PrimaryKey @ColumnInfo(name = "cookie_id") val id: Long,
val name: String?,
val cookie: String?,
val cookieMessenger: String? = null // Version 2
) : Parcelable {
override fun toString(): String = "CookieEntity(${hashCode()})"
override fun toString(): String = "CookieEntity(${hashCode()})"
fun toSensitiveString(): String =
"CookieEntity(id=$id, name=$name, cookie=$cookie cookieMessenger=$cookieMessenger)"
fun toSensitiveString(): String =
"CookieEntity(id=$id, name=$name, cookie=$cookie cookieMessenger=$cookieMessenger)"
}
@Dao
interface CookieDao {
@Query("SELECT * FROM cookies")
fun _selectAll(): List<CookieEntity>
@Query("SELECT * FROM cookies") fun _selectAll(): List<CookieEntity>
@Query("SELECT * FROM cookies WHERE cookie_id = :id")
fun _selectById(id: Long): CookieEntity?
@Query("SELECT * FROM cookies WHERE cookie_id = :id") fun _selectById(id: Long): CookieEntity?
@Insert(onConflict = OnConflictStrategy.REPLACE)
fun _save(cookie: CookieEntity)
@Insert(onConflict = OnConflictStrategy.REPLACE) fun _save(cookie: CookieEntity)
@Insert(onConflict = OnConflictStrategy.REPLACE)
fun _save(cookies: List<CookieEntity>)
@Insert(onConflict = OnConflictStrategy.REPLACE) fun _save(cookies: List<CookieEntity>)
@Query("DELETE FROM cookies WHERE cookie_id = :id")
fun _deleteById(id: Long)
@Query("DELETE FROM cookies WHERE cookie_id = :id") fun _deleteById(id: Long)
@Query("UPDATE cookies SET cookieMessenger = :cookie WHERE cookie_id = :id")
fun _updateMessengerCookie(id: Long, cookie: String?)
@Query("UPDATE cookies SET cookieMessenger = :cookie WHERE cookie_id = :id")
fun _updateMessengerCookie(id: Long, cookie: String?)
}
suspend fun CookieDao.selectAll() = dao { _selectAll() }
suspend fun CookieDao.selectById(id: Long) = dao { _selectById(id) }
suspend fun CookieDao.save(cookie: CookieEntity) = dao { _save(cookie) }
suspend fun CookieDao.save(cookies: List<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")
}
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) {
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
/**
* Wraps dao calls to work with coroutines
* Non transactional queries were supposed to be fixed in https://issuetracker.google.com/issues/69474692,
* but it still requires dispatch from a non ui thread.
* This avoids that constraint
* Wraps dao calls to work with coroutines Non transactional queries were supposed to be fixed in
* https://issuetracker.google.com/issues/69474692, but it still requires dispatch from a non ui
* thread. This avoids that constraint
*/
suspend inline fun <T> dao(crossinline block: () -> T) = withContext(Dispatchers.IO) { block() }

View File

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

View File

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

View File

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

View File

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

View File

@ -20,16 +20,14 @@ import androidx.annotation.StringRes
import com.pitchedapps.frost.R
import com.pitchedapps.frost.facebook.FbItem
/**
* Created by Allan Wang on 2017-06-23.
*/
/** Created by Allan Wang on 2017-06-23. */
enum class FeedSort(@StringRes val textRes: Int, val item: FbItem) {
DEFAULT(R.string.kau_default, FbItem.FEED),
MOST_RECENT(R.string.most_recent, FbItem.FEED_MOST_RECENT),
TOP(R.string.top_stories, FbItem.FEED_TOP_STORIES);
DEFAULT(R.string.kau_default, FbItem.FEED),
MOST_RECENT(R.string.most_recent, FbItem.FEED_MOST_RECENT),
TOP(R.string.top_stories, FbItem.FEED_TOP_STORIES);
companion object {
val values = values() // save one instance
operator fun invoke(index: Int) = values[index]
}
companion object {
val values = values() // save one instance
operator fun invoke(index: Int) = values[index]
}
}

View File

@ -19,29 +19,17 @@ package com.pitchedapps.frost.enums
import com.pitchedapps.frost.R
import com.pitchedapps.frost.injectors.ThemeProvider
/**
* Created by Allan Wang on 2017-08-19.
*/
/** Created by Allan Wang on 2017-08-19. */
enum class MainActivityLayout(
val titleRes: Int,
val backgroundColor: (ThemeProvider) -> Int,
val iconColor: (ThemeProvider) -> Int
val titleRes: Int,
val backgroundColor: (ThemeProvider) -> Int,
val iconColor: (ThemeProvider) -> Int
) {
TOP_BAR(R.string.top_bar, { it.headerColor }, { it.iconColor }),
BOTTOM_BAR(R.string.bottom_bar, { it.bgColor }, { it.textColor });
TOP_BAR(
R.string.top_bar,
{ it.headerColor },
{ it.iconColor }
),
BOTTOM_BAR(
R.string.bottom_bar,
{ it.bgColor },
{ it.textColor }
);
companion object {
val values = values() // save one instance
operator fun invoke(index: Int) = values.getOrElse(index) { TOP_BAR }
}
companion object {
val values = values() // save one instance
operator fun invoke(index: Int) = values.getOrElse(index) { TOP_BAR }
}
}

View File

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

View File

@ -23,95 +23,85 @@ import com.pitchedapps.frost.R
import com.pitchedapps.frost.prefs.sections.ThemePrefs
import java.util.Locale
/**
* Created by Allan Wang on 2017-06-14.
*/
/** Created by Allan Wang on 2017-06-14. */
const val FACEBOOK_BLUE = 0xff3b5998.toInt()
const val BLUE_LIGHT = 0xff5d86dd.toInt()
enum class Theme(
@StringRes val textRes: Int,
file: String?,
val textColorGetter: (ThemePrefs) -> Int,
val accentColorGetter: (ThemePrefs) -> Int,
val backgroundColorGetter: (ThemePrefs) -> Int,
val headerColorGetter: (ThemePrefs) -> Int,
val iconColorGetter: (ThemePrefs) -> Int
@StringRes val textRes: Int,
file: String?,
val textColorGetter: (ThemePrefs) -> Int,
val accentColorGetter: (ThemePrefs) -> Int,
val backgroundColorGetter: (ThemePrefs) -> Int,
val headerColorGetter: (ThemePrefs) -> Int,
val iconColorGetter: (ThemePrefs) -> Int
) {
DEFAULT(
R.string.kau_default,
"default",
{ 0xde000000.toInt() },
{ FACEBOOK_BLUE },
{ 0xfffafafa.toInt() },
{ FACEBOOK_BLUE },
{ Color.WHITE }
),
LIGHT(
R.string.kau_light,
"material_light",
{ 0xde000000.toInt() },
{ FACEBOOK_BLUE },
{ 0xfffafafa.toInt() },
{ FACEBOOK_BLUE },
{ Color.WHITE }
),
DARK(
R.string.kau_dark,
"material_dark",
{ Color.WHITE },
{ BLUE_LIGHT },
{ 0xff303030.toInt() },
{ 0xff2e4b86.toInt() },
{ Color.WHITE }
),
AMOLED(
R.string.kau_amoled,
"material_amoled",
{ Color.WHITE },
{ BLUE_LIGHT },
{ Color.BLACK },
{ Color.BLACK },
{ Color.WHITE }
),
GLASS(
R.string.kau_glass,
"material_glass",
{ Color.WHITE },
{ BLUE_LIGHT },
{ 0x80000000.toInt() },
{ 0xb3000000.toInt() },
{ Color.WHITE }
),
CUSTOM(
R.string.kau_custom,
"custom",
{ it.customTextColor },
{ it.customAccentColor },
{ it.customBackgroundColor },
{ it.customHeaderColor },
{ it.customIconColor }
);
DEFAULT(
R.string.kau_default,
"default",
{ 0xde000000.toInt() },
{ FACEBOOK_BLUE },
{ 0xfffafafa.toInt() },
{ FACEBOOK_BLUE },
{ Color.WHITE }
),
@VisibleForTesting internal val file = file?.let { "$it.css" }
LIGHT(
R.string.kau_light,
"material_light",
{ 0xde000000.toInt() },
{ FACEBOOK_BLUE },
{ 0xfffafafa.toInt() },
{ FACEBOOK_BLUE },
{ Color.WHITE }
),
DARK(
R.string.kau_dark,
"material_dark",
{ Color.WHITE },
{ BLUE_LIGHT },
{ 0xff303030.toInt() },
{ 0xff2e4b86.toInt() },
{ Color.WHITE }
),
AMOLED(
R.string.kau_amoled,
"material_amoled",
{ Color.WHITE },
{ BLUE_LIGHT },
{ Color.BLACK },
{ Color.BLACK },
{ Color.WHITE }
),
GLASS(
R.string.kau_glass,
"material_glass",
{ Color.WHITE },
{ BLUE_LIGHT },
{ 0x80000000.toInt() },
{ 0xb3000000.toInt() },
{ Color.WHITE }
),
CUSTOM(
R.string.kau_custom,
"custom",
{ it.customTextColor },
{ it.customAccentColor },
{ it.customBackgroundColor },
{ it.customHeaderColor },
{ it.customIconColor }
);
@VisibleForTesting
internal val file = file?.let { "$it.css" }
companion object {
val values = values() // save one instance
operator fun invoke(index: Int) = values[index]
}
companion object {
val values = values() // save one instance
operator fun invoke(index: Int) = values[index]
}
}
enum class ThemeCategory {
FACEBOOK, MESSENGER
;
FACEBOOK,
MESSENGER;
@VisibleForTesting
internal val folder = name.toLowerCase(Locale.CANADA)
@VisibleForTesting internal val folder = name.toLowerCase(Locale.CANADA)
}

View File

@ -16,10 +16,7 @@
*/
package com.pitchedapps.frost.facebook
/**
* Created by Allan Wang on 2017-06-01.
*/
/** Created by Allan Wang on 2017-06-01. */
const val FACEBOOK_COM = "facebook.com"
const val MESSENGER_COM = "messenger.com"
const val FBCDN_NET = "fbcdn.net"
@ -31,7 +28,9 @@ const val FACEBOOK_BASE_COM = "m.$FACEBOOK_COM"
const val FB_URL_BASE = "https://$FACEBOOK_BASE_COM/"
const val FACEBOOK_MBASIC_COM = "mbasic.$FACEBOOK_COM"
const val FB_URL_MBASIC_BASE = "https://$FACEBOOK_MBASIC_COM/"
fun profilePictureUrl(id: Long) = "https://graph.facebook.com/$id/picture?type=large"
const val FB_LOGIN_URL = "${FB_URL_BASE}login"
const val FB_HOME_URL = "${FB_URL_BASE}home.php"
const val MESSENGER_THREAD_PREFIX = "$HTTPS_MESSENGER_COM/t/"
@ -45,23 +44,19 @@ const val MESSENGER_THREAD_PREFIX = "$HTTPS_MESSENGER_COM/t/"
// Default user agent
const val USER_AGENT_MOBILE_CONST =
"Mozilla/5.0 (Linux; Android 8.0.0; ONEPLUS A3000) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/73.0.3683.90 Mobile Safari/537.36"
"Mozilla/5.0 (Linux; Android 8.0.0; ONEPLUS A3000) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/73.0.3683.90 Mobile Safari/537.36"
// Desktop agent, for pages like messages
const val USER_AGENT_DESKTOP_CONST =
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/73.0.3683.90 Safari/537.36"
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/73.0.3683.90 Safari/537.36"
const val USER_AGENT = USER_AGENT_DESKTOP_CONST
/**
* Animation transition delay, just to ensure that the styles
* have properly set in
*/
/** Animation transition delay, just to ensure that the styles have properly set in */
const val WEB_LOAD_DELAY = 50L
/**
* Additional delay for transition when called from commit.
* Note that transitions are also called from onFinish, so this value
* will never make a load slower than it is
* Additional delay for transition when called from commit. Note that transitions are also called
* from onFinish, so this value will never make a load slower than it is
*/
const val WEB_COMMIT_LOAD_DELAY = 200L

View File

@ -28,149 +28,129 @@ import com.pitchedapps.frost.prefs.Prefs
import com.pitchedapps.frost.utils.L
import com.pitchedapps.frost.utils.cookies
import com.pitchedapps.frost.utils.launchLogin
import javax.inject.Inject
import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.withContext
import javax.inject.Inject
import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine
/**
* Created by Allan Wang on 2017-05-30.
*
* The following component manages all cookie transfers.
*/
class FbCookie @Inject internal constructor(
private val prefs: Prefs,
private val cookieDao: CookieDao
) {
class FbCookie
@Inject
internal constructor(private val prefs: Prefs, private val cookieDao: CookieDao) {
companion object {
/**
* Domain information. Dot prefix still matters for Android browsers.
*/
private const val FB_COOKIE_DOMAIN = ".$FACEBOOK_COM"
private const val MESSENGER_COOKIE_DOMAIN = ".$MESSENGER_COM"
companion object {
/** Domain information. Dot prefix still matters for Android browsers. */
private const val FB_COOKIE_DOMAIN = ".$FACEBOOK_COM"
private const val MESSENGER_COOKIE_DOMAIN = ".$MESSENGER_COM"
}
/** Retrieves the facebook cookie if it exists Note that this is a synchronized call */
val webCookie: String?
get() = CookieManager.getInstance().getCookie(HTTPS_FACEBOOK_COM)
val messengerCookie: String?
get() = CookieManager.getInstance().getCookie(HTTPS_MESSENGER_COM)
private suspend fun CookieManager.suspendSetWebCookie(domain: String, cookie: String?): Boolean {
cookie ?: return true
return withContext(NonCancellable) {
// Save all cookies regardless of result, then check if all succeeded
val result =
cookie.split(";").map { async { setSingleWebCookie(domain, it) } }.awaitAll().all { it }
L.d { "Cookies set" }
L._d { "Set $cookie\n\tResult $webCookie" }
result
}
}
private suspend fun CookieManager.setSingleWebCookie(domain: String, cookie: String): Boolean =
suspendCoroutine { cont ->
setCookie(domain, cookie.trim()) { cont.resume(it) }
}
/**
* Retrieves the facebook cookie if it exists
* Note that this is a synchronized call
*/
val webCookie: String?
get() = CookieManager.getInstance().getCookie(HTTPS_FACEBOOK_COM)
private suspend fun CookieManager.removeAllCookies(): Boolean = suspendCoroutine { cont ->
removeAllCookies { cont.resume(it) }
}
val messengerCookie: String?
get() = CookieManager.getInstance().getCookie(HTTPS_MESSENGER_COM)
suspend fun save(id: Long) {
L.d { "New cookie found" }
prefs.userId = id
CookieManager.getInstance().flush()
val cookie = CookieEntity(prefs.userId, null, webCookie)
cookieDao.save(cookie)
}
private suspend fun CookieManager.suspendSetWebCookie(
domain: String,
cookie: String?
): Boolean {
cookie ?: return true
return withContext(NonCancellable) {
// Save all cookies regardless of result, then check if all succeeded
val result = cookie.split(";")
.map { async { setSingleWebCookie(domain, it) } }
.awaitAll().all { it }
L.d { "Cookies set" }
L._d { "Set $cookie\n\tResult $webCookie" }
result
}
suspend fun reset() {
prefs.userId = -1L
withContext(Dispatchers.Main + NonCancellable) {
with(CookieManager.getInstance()) {
removeAllCookies()
flush()
}
}
}
private suspend fun CookieManager.setSingleWebCookie(domain: String, cookie: String): Boolean =
suspendCoroutine { cont ->
setCookie(domain, cookie.trim()) {
cont.resume(it)
}
}
suspend fun switchUser(id: Long) {
val cookie = cookieDao.selectById(id) ?: return L.e { "No cookie for id" }
switchUser(cookie)
}
private suspend fun CookieManager.removeAllCookies(): Boolean = suspendCoroutine { cont ->
removeAllCookies {
cont.resume(it)
}
suspend fun switchUser(cookie: CookieEntity?) {
if (cookie?.cookie == null) {
L.d { "Switching User; null cookie" }
return
}
suspend fun save(id: Long) {
L.d { "New cookie found" }
prefs.userId = id
CookieManager.getInstance().flush()
val cookie = CookieEntity(prefs.userId, null, webCookie)
cookieDao.save(cookie)
withContext(Dispatchers.Main + NonCancellable) {
L.d { "Switching User" }
prefs.userId = cookie.id
CookieManager.getInstance().apply {
removeAllCookies()
suspendSetWebCookie(FB_COOKIE_DOMAIN, cookie.cookie)
suspendSetWebCookie(MESSENGER_COOKIE_DOMAIN, cookie.cookieMessenger)
flush()
}
}
}
suspend fun reset() {
prefs.userId = -1L
withContext(Dispatchers.Main + NonCancellable) {
with(CookieManager.getInstance()) {
removeAllCookies()
flush()
}
}
}
/** Helper function to remove the current cookies and launch the proper login page */
suspend fun logout(context: Context, deleteCookie: Boolean = true) {
val cookies = arrayListOf<CookieEntity>()
if (context is Activity) cookies.addAll(context.cookies().filter { it.id != prefs.userId })
logout(prefs.userId, deleteCookie)
context.launchLogin(cookies, true)
}
suspend fun switchUser(id: Long) {
val cookie = cookieDao.selectById(id) ?: return L.e { "No cookie for id" }
switchUser(cookie)
/** Clear the cookies of the given id */
suspend fun logout(id: Long, deleteCookie: Boolean = true) {
L.d { "Logging out user" }
if (deleteCookie) {
cookieDao.deleteById(id)
L.d { "Fb cookie deleted" }
L._d { id }
}
reset()
}
suspend fun switchUser(cookie: CookieEntity?) {
if (cookie?.cookie == null) {
L.d { "Switching User; null cookie" }
return
}
withContext(Dispatchers.Main + NonCancellable) {
L.d { "Switching User" }
prefs.userId = cookie.id
CookieManager.getInstance().apply {
removeAllCookies()
suspendSetWebCookie(FB_COOKIE_DOMAIN, cookie.cookie)
suspendSetWebCookie(MESSENGER_COOKIE_DOMAIN, cookie.cookieMessenger)
flush()
}
}
}
/**
* Helper function to remove the current cookies
* and launch the proper login page
*/
suspend fun logout(context: Context, deleteCookie: Boolean = true) {
val cookies = arrayListOf<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" }
}
/**
* 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
enum class FbItem(
@StringRes val titleId: Int,
val icon: IIcon,
relativeUrl: String,
val fragmentCreator: () -> BaseFragment = ::WebFragment,
prefix: String = FB_URL_BASE
@StringRes val titleId: Int,
val icon: IIcon,
relativeUrl: String,
val fragmentCreator: () -> BaseFragment = ::WebFragment,
prefix: String = FB_URL_BASE
) : EnumBundle<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"),
CHAT(R.string.chat, GoogleMaterial.Icon.gmd_chat, "buddylist"),
EVENTS(R.string.events, GoogleMaterial.Icon.gmd_event_note, "events/upcoming"),
FEED(R.string.feed, CommunityMaterial.Icon3.cmd_newspaper, ""),
FEED_MOST_RECENT(R.string.most_recent, GoogleMaterial.Icon.gmd_history, "home.php?sk=h_chr"),
FEED_TOP_STORIES(R.string.top_stories, GoogleMaterial.Icon.gmd_star, "home.php?sk=h_nor"),
FRIENDS(R.string.friends, GoogleMaterial.Icon.gmd_person_add, "friends/center/requests"),
GROUPS(R.string.groups, GoogleMaterial.Icon.gmd_group, "groups"),
MARKETPLACE(R.string.marketplace, GoogleMaterial.Icon.gmd_store, "marketplace"),
/*
* Unlike other urls, menus cannot be linked directly as it is a soft reference. Instead, we can
* pick any url with the blue bar and manually click to enter the menu.
* We pick home.php as some back interactions default to home regardless of the base url.
*/
MENU(R.string.menu, GoogleMaterial.Icon.gmd_menu, "home.php"),
MESSAGES(R.string.messages, MaterialDesignIconic.Icon.gmi_comments, "messages"),
MESSENGER(
R.string.messenger,
CommunityMaterial.Icon2.cmd_facebook_messenger,
"",
prefix = HTTPS_MESSENGER_COM
),
NOTES(R.string.notes, CommunityMaterial.Icon3.cmd_note, "notes"),
NOTIFICATIONS(R.string.notifications, MaterialDesignIconic.Icon.gmi_globe, "notifications"),
ON_THIS_DAY(R.string.on_this_day, GoogleMaterial.Icon.gmd_today, "onthisday"),
PAGES(R.string.pages, GoogleMaterial.Icon.gmd_flag, "pages"),
PHOTOS(R.string.photos, GoogleMaterial.Icon.gmd_photo, "me/photos"),
PROFILE(R.string.profile, CommunityMaterial.Icon.cmd_account, "me"),
SAVED(R.string.saved, GoogleMaterial.Icon.gmd_bookmark, "saved"),
/*
* Unlike other urls, menus cannot be linked directly as it is a soft reference. Instead, we can
* pick any url with the blue bar and manually click to enter the menu.
* We pick home.php as some back interactions default to home regardless of the base url.
*/
MENU(R.string.menu, GoogleMaterial.Icon.gmd_menu, "home.php"),
MESSAGES(R.string.messages, MaterialDesignIconic.Icon.gmi_comments, "messages"),
MESSENGER(
R.string.messenger,
CommunityMaterial.Icon2.cmd_facebook_messenger,
"",
prefix = HTTPS_MESSENGER_COM
),
NOTES(R.string.notes, CommunityMaterial.Icon3.cmd_note, "notes"),
NOTIFICATIONS(R.string.notifications, MaterialDesignIconic.Icon.gmi_globe, "notifications"),
ON_THIS_DAY(R.string.on_this_day, GoogleMaterial.Icon.gmd_today, "onthisday"),
PAGES(R.string.pages, GoogleMaterial.Icon.gmd_flag, "pages"),
PHOTOS(R.string.photos, GoogleMaterial.Icon.gmd_photo, "me/photos"),
PROFILE(R.string.profile, CommunityMaterial.Icon.cmd_account, "me"),
SAVED(R.string.saved, GoogleMaterial.Icon.gmd_bookmark, "saved"),
/** Note that this url only works if a query (?q=) is provided */
_SEARCH(R.string.kau_search, GoogleMaterial.Icon.gmd_search, "search/top"),
/**
* Note that this url only works if a query (?q=) is provided
*/
_SEARCH(
R.string.kau_search,
GoogleMaterial.Icon.gmd_search,
"search/top"
),
/** Non mbasic search cannot be parsed. */
_SEARCH_PARSE(
R.string.kau_search,
GoogleMaterial.Icon.gmd_search,
"search/top",
prefix = FB_URL_MBASIC_BASE
),
SETTINGS(R.string.settings, GoogleMaterial.Icon.gmd_settings, "settings"),
;
/**
* Non mbasic search cannot be parsed.
*/
_SEARCH_PARSE(
R.string.kau_search,
GoogleMaterial.Icon.gmd_search,
"search/top",
prefix = FB_URL_MBASIC_BASE
),
SETTINGS(R.string.settings, GoogleMaterial.Icon.gmd_settings, "settings"),
;
val url = "$prefix$relativeUrl"
val url = "$prefix$relativeUrl"
val isFeed: Boolean
get() =
when (this) {
FEED,
FEED_MOST_RECENT,
FEED_TOP_STORIES -> true
else -> false
}
val isFeed: Boolean
get() = when (this) {
FEED, FEED_MOST_RECENT, FEED_TOP_STORIES -> true
else -> false
}
override val bundleContract: EnumBundleCompanion<FbItem>
get() = Companion
override val bundleContract: EnumBundleCompanion<FbItem>
get() = Companion
companion object : EnumCompanion<FbItem>("frost_arg_fb_item", values())
companion object : EnumCompanion<FbItem>("frost_arg_fb_item", values())
}
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.
*
* Collection of regex matchers
* Input text must be properly unescaped
* Collection of regex matchers Input text must be properly unescaped
*
* See [StringEscapeUtils]
*/
/**
* Matches the fb_dtsg component of a page containing it as a hidden value
*/
/** Matches the fb_dtsg component of a page containing it as a hidden value */
val FB_DTSG_MATCHER: Regex by lazy { Regex("name=\"fb_dtsg\" value=\"(.*?)\"") }
val FB_REV_MATCHER: Regex by lazy { Regex("\"app_version\":\"(.*?)\"") }
/**
* Matches user id from cookie
*/
/** Matches user id from cookie */
val FB_USER_MATCHER: Regex = Regex("c_user=([0-9]*);")
val FB_EPOCH_MATCHER: Regex = Regex(":([0-9]+)")

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -24,23 +24,19 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeout
/**
* Created by Allan Wang on 29/12/17.
*/
/** Created by Allan Wang on 29/12/17. */
/**
* Attempts to get the fbcdn url of the supplied image redirect url
*/
/** Attempts to get the fbcdn url of the supplied image redirect url */
suspend fun String.getFullSizedImageUrl(url: String, timeout: Long = 3000): String? =
withContext(Dispatchers.IO) {
try {
withTimeout(timeout) {
val redirect = requestBuilder().url(url).get().call()
.execute().body?.string() ?: return@withTimeout null
FB_REDIRECT_URL_MATCHER.find(redirect)[1]?.formattedFbUrl
}
} catch (e: Exception) {
L.e(e) { "Failed to load full size image url" }
null
}
withContext(Dispatchers.IO) {
try {
withTimeout(timeout) {
val redirect =
requestBuilder().url(url).get().call().execute().body?.string() ?: return@withTimeout null
FB_REDIRECT_URL_MATCHER.find(redirect)[1]?.formattedFbUrl
}
} catch (e: Exception) {
L.e(e) { "Failed to load full size image url" }
null
}
}

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

View File

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

View File

@ -32,119 +32,113 @@ import com.pitchedapps.frost.views.FrostRecyclerView
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
/**
* Created by Allan Wang on 27/12/17.
*/
/** Created by Allan Wang on 27/12/17. */
abstract class RecyclerFragment<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() {
val core = core ?: return
if (firstLoad) {
core.reloadBase(true)
firstLoad = false
override fun firstLoadRequest() {
val core = core ?: return
if (firstLoad) {
core.reloadBase(true)
firstLoad = false
}
}
final override suspend fun reload(progress: (Int) -> Unit): Boolean =
withContext(Dispatchers.IO) {
val data =
try {
reloadImpl(progress)
} catch (e: Exception) {
L.e(e) { "Recycler reload fail $baseUrl" }
null
}
withMainContext {
if (data == null) {
valid = false
false
} else {
adapter.setNewList(data)
true
}
}
}
final override suspend fun reload(progress: (Int) -> Unit): Boolean =
withContext(Dispatchers.IO) {
val data = try {
reloadImpl(progress)
} catch (e: Exception) {
L.e(e) { "Recycler reload fail $baseUrl" }
null
}
withMainContext {
if (data == null) {
valid = false
false
} else {
adapter.setNewList(data)
true
}
}
}
protected abstract suspend fun reloadImpl(progress: (Int) -> Unit): List<T>?
protected abstract suspend fun reloadImpl(progress: (Int) -> Unit): List<T>?
}
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) {
recyclerView.adapter = getAdapter()
recyclerView.onReloadClear = { adapter.clear() }
bindImpl(recyclerView)
}
final override fun bind(recyclerView: FrostRecyclerView) {
recyclerView.adapter = getAdapter()
recyclerView.onReloadClear = { adapter.clear() }
bindImpl(recyclerView)
}
/**
* Anything to call for one time bindings
* At this stage, all adapters will have FastAdapter references
*/
open fun bindImpl(recyclerView: FrostRecyclerView) = Unit
/**
* Anything to call for one time bindings At this stage, all adapters will have FastAdapter
* references
*/
open fun bindImpl(recyclerView: FrostRecyclerView) = Unit
/**
* Create the fast adapter to bind to the recyclerview
*/
open fun getAdapter(): GenericFastAdapter = fastAdapter(this.adapter)
/** Create the fast adapter to bind to the recyclerview */
open fun getAdapter(): GenericFastAdapter = fastAdapter(this.adapter)
}
abstract class FrostParserFragment<T : ParseData, Item : GenericItem> :
RecyclerFragment<Item, Item>() {
RecyclerFragment<Item, Item>() {
/**
* The parser to make this all happen
*/
abstract val parser: FrostParser<T>
/** The parser to make this all happen */
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) {
recyclerView.adapter = getAdapter()
recyclerView.onReloadClear = { adapter.clear() }
bindImpl(recyclerView)
}
final override fun bind(recyclerView: FrostRecyclerView) {
recyclerView.adapter = getAdapter()
recyclerView.onReloadClear = { adapter.clear() }
bindImpl(recyclerView)
}
/**
* Anything to call for one time bindings
* At this stage, all adapters will have FastAdapter references
*/
open fun bindImpl(recyclerView: FrostRecyclerView) = Unit
/**
* Anything to call for one time bindings At this stage, all adapters will have FastAdapter
* references
*/
open fun bindImpl(recyclerView: FrostRecyclerView) = Unit
/**
* Create the fast adapter to bind to the recyclerview
*/
open fun getAdapter(): GenericFastAdapter = fastAdapter(this.adapter)
/** Create the fast adapter to bind to the recyclerview */
open fun getAdapter(): GenericFastAdapter = fastAdapter(this.adapter)
override suspend fun reloadImpl(progress: (Int) -> Unit): List<Item>? =
withContext(Dispatchers.IO) {
progress(10)
val cookie = fbCookie.webCookie
val doc = getDoc(cookie)
progress(60)
val response = try {
parser.parse(cookie, doc)
} catch (ignored: Exception) {
null
}
if (response == null) {
L.i { "RecyclerFragment failed for ${baseEnum.name}" }
L._d { "Cookie used: $cookie" }
return@withContext null
}
progress(80)
val items = toItems(response)
progress(97)
return@withContext items
override suspend fun reloadImpl(progress: (Int) -> Unit): List<Item>? =
withContext(Dispatchers.IO) {
progress(10)
val cookie = fbCookie.webCookie
val doc = getDoc(cookie)
progress(60)
val response =
try {
parser.parse(cookie, doc)
} catch (ignored: Exception) {
null
}
if (response == null) {
L.i { "RecyclerFragment failed for ${baseEnum.name}" }
L._d { "Cookie used: $cookie" }
return@withContext null
}
progress(80)
val items = toItems(response)
progress(97)
return@withContext items
}
}

View File

@ -27,19 +27,22 @@ import com.pitchedapps.frost.views.FrostRecyclerView
/**
* Created by Allan Wang on 27/12/17.
*
* Retained as an example. Deletion made at https://github.com/AllanWang/Frost-for-Facebook/pull/1542
* Retained as an example. Deletion made at
* https://github.com/AllanWang/Frost-for-Facebook/pull/1542
*/
@Deprecated(message = "Retained as an example; currently does not support marking a notification as read")
@Deprecated(
message = "Retained as an example; currently does not support marking a notification as read"
)
class NotificationFragment : FrostParserFragment<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> =
response.data.notifs.map { NotificationIItem(it, response.cookie, themeProvider) }
override fun toItems(response: ParseResponse<FrostNotifs>): List<NotificationIItem> =
response.data.notifs.map { NotificationIItem(it, response.cookie, themeProvider) }
override fun bindImpl(recyclerView: FrostRecyclerView) {
NotificationIItem.bindEvents(adapter, fbCookie, prefs, themeProvider)
}
override fun bindImpl(recyclerView: FrostRecyclerView) {
NotificationIItem.bindEvents(adapter, fbCookie, prefs, themeProvider)
}
}

View File

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

View File

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

View File

@ -32,101 +32,86 @@ import com.pitchedapps.frost.injectors.ThemeProvider
import com.pitchedapps.frost.prefs.Prefs
import com.pitchedapps.frost.utils.launchWebOverlay
/**
* Created by Allan Wang on 30/12/17.
*/
/** Created by Allan Wang on 30/12/17. */
/**
* Base contract for anything with a url that may be launched in a new overlay
*/
/** Base contract for anything with a url that may be launched in a new overlay */
interface ClickableIItemContract {
val url: String?
val url: String?
fun click(context: Context, fbCookie: FbCookie, prefs: Prefs) {
val url = url ?: return
context.launchWebOverlay(url, fbCookie, prefs)
}
fun click(context: Context, fbCookie: FbCookie, prefs: Prefs) {
val url = url ?: return
context.launchWebOverlay(url, fbCookie, prefs)
}
companion object {
fun bindEvents(adapter: IAdapter<GenericItem>, fbCookie: FbCookie, prefs: Prefs) {
adapter.fastAdapter?.apply {
selectExtension {
isSelectable = false
}
onClickListener = { v, _, item, _ ->
if (item is ClickableIItemContract) {
item.click(v!!.context, fbCookie, prefs)
true
} else
false
}
}
companion object {
fun bindEvents(adapter: IAdapter<GenericItem>, fbCookie: FbCookie, prefs: Prefs) {
adapter.fastAdapter?.apply {
selectExtension { isSelectable = false }
onClickListener = { v, _, item, _ ->
if (item is ClickableIItemContract) {
item.click(v!!.context, fbCookie, prefs)
true
} else false
}
}
}
}
}
/**
* Generic header item
* Not clickable with an accent color
*/
/** Generic header item Not clickable with an accent color */
open class HeaderIItem(
val text: String?,
itemId: Int = R.layout.iitem_header,
private val themeProvider: ThemeProvider
) : KauIItem<HeaderIItem.ViewHolder>(
val text: String?,
itemId: Int = R.layout.iitem_header,
private val themeProvider: ThemeProvider
) :
KauIItem<HeaderIItem.ViewHolder>(
R.layout.iitem_header,
{ ViewHolder(it, themeProvider) },
itemId
) {
) {
class ViewHolder(
itemView: View,
private val themeProvider: ThemeProvider
) : FastAdapter.ViewHolder<HeaderIItem>(itemView) {
class ViewHolder(itemView: View, 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>) {
text.setTextColor(themeProvider.accentColor)
text.text = item.text
text.setBackgroundColor(themeProvider.nativeBgColor)
}
override fun unbindView(item: HeaderIItem) {
text.text = null
}
override fun bindView(item: HeaderIItem, payloads: List<Any>) {
text.setTextColor(themeProvider.accentColor)
text.text = item.text
text.setBackgroundColor(themeProvider.nativeBgColor)
}
override fun unbindView(item: HeaderIItem) {
text.text = null
}
}
}
/**
* Generic text item
* Clickable with text color
*/
/** Generic text item Clickable with text color */
open class TextIItem(
val text: String?,
override val url: String?,
itemId: Int = R.layout.iitem_text,
private val themeProvider: ThemeProvider
) : KauIItem<TextIItem.ViewHolder>(R.layout.iitem_text, { ViewHolder(it, themeProvider) }, itemId),
ClickableIItemContract {
val text: String?,
override val url: String?,
itemId: Int = R.layout.iitem_text,
private val themeProvider: ThemeProvider
) :
KauIItem<TextIItem.ViewHolder>(R.layout.iitem_text, { ViewHolder(it, themeProvider) }, itemId),
ClickableIItemContract {
class ViewHolder(
itemView: View,
private val themeProvider: ThemeProvider
) : FastAdapter.ViewHolder<TextIItem>(itemView) {
class ViewHolder(itemView: View, 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>) {
text.setTextColor(themeProvider.textColor)
text.text = item.text
text.background =
createSimpleRippleDrawable(themeProvider.bgColor, themeProvider.nativeBgColor)
}
override fun unbindView(item: TextIItem) {
text.text = null
}
override fun bindView(item: TextIItem, payloads: List<Any>) {
text.setTextColor(themeProvider.textColor)
text.text = item.text
text.background =
createSimpleRippleDrawable(themeProvider.bgColor, themeProvider.nativeBgColor)
}
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.launchWebOverlay
/**
* Created by Allan Wang on 27/12/17.
*/
/** Created by Allan Wang on 27/12/17. */
class NotificationIItem(
val notification: FrostNotif,
val cookie: String,
private val themeProvider: ThemeProvider
) : KauIItem<NotificationIItem.ViewHolder>(
R.layout.iitem_notification, { ViewHolder(it, themeProvider) }
) {
val notification: FrostNotif,
val cookie: String,
private val themeProvider: ThemeProvider
) :
KauIItem<NotificationIItem.ViewHolder>(
R.layout.iitem_notification,
{ ViewHolder(it, themeProvider) }
) {
companion object {
fun bindEvents(
adapter: ItemAdapter<NotificationIItem>,
fbCookie: FbCookie,
prefs: Prefs,
themeProvider: ThemeProvider
) {
adapter.fastAdapter?.apply {
selectExtension {
isSelectable = false
}
onClickListener = { v, _, item, position ->
val notif = item.notification
if (notif.unread) {
adapter.set(
position,
NotificationIItem(
notif.copy(unread = false),
item.cookie,
themeProvider
)
)
}
// TODO temp fix. If url is dependent, we cannot load it directly
v!!.context.launchWebOverlay(
if (notif.url.isIndependent) notif.url else FbItem.NOTIFICATIONS.url,
fbCookie,
prefs
)
true
}
}
}
// todo see if necessary
val DIFF: DiffCallback<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)
companion object {
fun bindEvents(
adapter: ItemAdapter<NotificationIItem>,
fbCookie: FbCookie,
prefs: Prefs,
themeProvider: ThemeProvider
) {
adapter.fastAdapter?.apply {
selectExtension { isSelectable = false }
onClickListener = { v, _, item, position ->
val notif = item.notification
if (notif.unread) {
adapter.set(
position,
NotificationIItem(notif.copy(unread = false), item.cookie, themeProvider)
)
content.setTextColor(themeProvider.textColor)
date.setTextColor(themeProvider.textColor.withAlpha(150))
val glide = glide
glide.load(notif.img)
.transform(FrostGlide.circleCrop)
.into(avatar)
if (notif.thumbnailUrl != null)
glide.load(notif.thumbnailUrl).into(thumbnail.visible())
content.text = notif.content
date.text = notif.timeString
}
override fun unbindView(item: NotificationIItem) {
frame.background = null
val glide = glide
glide.clear(avatar)
glide.clear(thumbnail)
thumbnail.gone()
content.text = null
date.text = null
}
// TODO temp fix. If url is dependent, we cannot load it directly
v!!
.context
.launchWebOverlay(
if (notif.url.isIndependent) notif.url else FbItem.NOTIFICATIONS.url,
fbCookie,
prefs
)
true
}
}
}
// todo see if necessary
val DIFF: DiffCallback<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.injectors.ThemeProvider
/**
* Created by Allan Wang on 26/11/17.
*/
/** Created by Allan Wang on 26/11/17. */
class TabIItem(val item: FbItem, private val themeProvider: ThemeProvider) :
KauIItem<TabIItem.ViewHolder>(
R.layout.iitem_tab_preview,
{ ViewHolder(it, themeProvider) }
),
IDraggable {
KauIItem<TabIItem.ViewHolder>(R.layout.iitem_tab_preview, { ViewHolder(it, themeProvider) }),
IDraggable {
override val isDraggable: Boolean = true
override val isDraggable: Boolean = true
class ViewHolder(
itemView: View,
private val themeProvider: ThemeProvider
) : FastAdapter.ViewHolder<TabIItem>(itemView) {
class ViewHolder(itemView: View, private val themeProvider: ThemeProvider) :
FastAdapter.ViewHolder<TabIItem>(itemView) {
val image: ImageView by bindView(R.id.image)
val text: TextView by bindView(R.id.text)
val image: ImageView by bindView(R.id.image)
val text: TextView by bindView(R.id.text)
override fun bindView(item: TabIItem, payloads: List<Any>) {
val isInToolbar = adapterPosition < 4
val color = if (isInToolbar) themeProvider.iconColor else themeProvider.textColor
image.setIcon(item.item.icon, 20, color)
if (isInToolbar)
text.invisible()
else {
text.visible().setText(item.item.titleId)
text.setTextColor(color.withAlpha(200))
}
}
override fun unbindView(item: TabIItem) {
image.setImageDrawable(null)
text.visible().text = null
}
override fun bindView(item: TabIItem, payloads: List<Any>) {
val isInToolbar = adapterPosition < 4
val color = if (isInToolbar) themeProvider.iconColor else themeProvider.textColor
image.setIcon(item.item.icon, 20, color)
if (isInToolbar) text.invisible()
else {
text.visible().setText(item.item.titleId)
text.setTextColor(color.withAlpha(200))
}
}
override fun unbindView(item: TabIItem) {
image.setImageDrawable(null)
text.visible().text = null
}
}
}

View File

@ -19,23 +19,24 @@ package com.pitchedapps.frost.injectors
import android.webkit.WebView
import com.pitchedapps.frost.prefs.Prefs
/**
* Small misc inline css assets
*/
/** Small misc inline css assets */
enum class CssAsset(private val content: String) : InjectorContract {
FullSizeImage("div._4prr[style*=\"max-width\"][style*=\"max-height\"]{max-width:none !important;max-height:none !important}"),
FullSizeImage(
"div._4prr[style*=\"max-width\"][style*=\"max-height\"]{max-width:none !important;max-height:none !important}"
),
/*
* Remove top margin and hide some contents from the top bar and home page (as it's our base url)
*/
Menu("#bookmarks_flyout{margin-top:0 !important}#m_news_feed_stream,#MComposer{display:none !important}")
;
/*
* Remove top margin and hide some contents from the top bar and home page (as it's our base url)
*/
Menu(
"#bookmarks_flyout{margin-top:0 !important}#m_news_feed_stream,#MComposer{display:none !important}"
);
val injector: JsInjector by lazy {
JsBuilder().css(content).single("css-small-assets-$name").build()
}
val injector: JsInjector by lazy {
JsBuilder().css(content).single("css-small-assets-$name").build()
}
override fun inject(webView: WebView, prefs: Prefs) {
injector.inject(webView, prefs)
}
override fun inject(webView: WebView, prefs: Prefs) {
injector.inject(webView, prefs)
}
}

View File

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

View File

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

View File

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

View File

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

View File

@ -35,157 +35,151 @@ import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.BufferedReader
import java.io.FileNotFoundException
import javax.inject.Inject
import javax.inject.Singleton
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
interface ThemeProvider {
val textColor: Int
val textColor: Int
val accentColor: Int
val accentColor: Int
val accentColorForWhite: Int
val accentColorForWhite: Int
val nativeBgColor: Int
val nativeBgColor: Int
fun nativeBgColor(unread: Boolean): Int
fun nativeBgColor(unread: Boolean): Int
val bgColor: Int
val bgColor: Int
val headerColor: Int
val headerColor: Int
val iconColor: Int
val iconColor: Int
val isCustomTheme: Boolean
val isCustomTheme: Boolean
/**
* Note that while this can be loaded from any thread, it is typically done through [preload]]
*/
fun injector(category: ThemeCategory): InjectorContract
/** Note that while this can be loaded from any thread, it is typically done through [preload]] */
fun injector(category: ThemeCategory): InjectorContract
fun setTheme(id: Int)
fun setTheme(id: Int)
fun reset()
fun reset()
suspend fun preload()
suspend fun preload()
}
/**
* Provides [InjectorContract] for each [ThemeCategory].
* Can be reloaded to take in changes from [Prefs]
* Provides [InjectorContract] for each [ThemeCategory]. Can be reloaded to take in changes from
* [Prefs]
*/
class ThemeProviderImpl @Inject internal constructor(
@ApplicationContext private val context: Context,
private val prefs: Prefs
) : ThemeProvider {
class ThemeProviderImpl
@Inject
internal constructor(@ApplicationContext private val context: Context, private val prefs: Prefs) :
ThemeProvider {
private var theme: Theme = Theme.values[prefs.theme]
set(value) {
field = value
prefs.theme = value.ordinal
}
private val injectors: MutableMap<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
}
private var theme: Theme = Theme.values[prefs.theme]
set(value) {
field = value
prefs.theme = value.ordinal
}
override fun setTheme(id: Int) {
if (theme.ordinal == id) return
theme = Theme.values[id]
reset()
}
private val injectors: MutableMap<ThemeCategory, InjectorContract> = mutableMapOf()
override fun reset() {
injectors.clear()
}
override val textColor: Int
get() = theme.textColorGetter(prefs)
override suspend fun preload() {
withContext(Dispatchers.IO) {
reset()
ThemeCategory.values().forEach { injector(it) }
}
override val accentColor: Int
get() = theme.accentColorGetter(prefs)
override val accentColorForWhite: Int
get() =
when {
accentColor.isColorVisibleOn(Color.WHITE) -> accentColor
textColor.isColorVisibleOn(Color.WHITE) -> textColor
else -> FACEBOOK_BLUE
}
override val nativeBgColor: Int
get() = bgColor.withAlpha(30)
override fun nativeBgColor(unread: Boolean) =
bgColor.colorToForeground(if (unread) 0.7f else 0.0f).withAlpha(30)
override val bgColor: Int
get() = theme.backgroundColorGetter(prefs)
override val headerColor: Int
get() = theme.headerColorGetter(prefs)
override val iconColor: Int
get() = theme.iconColorGetter(prefs)
override val isCustomTheme: Boolean
get() = theme == Theme.CUSTOM
override fun injector(category: ThemeCategory): InjectorContract =
injectors.getOrPut(category) { createInjector(category) }
/** Note that while this can be loaded from any thread, it is typically done through [preload] */
private fun createInjector(category: ThemeCategory): InjectorContract {
val file = theme.file ?: return JsActions.EMPTY
try {
var content =
context.assets
.open("css/${category.folder}/themes/$file")
.bufferedReader()
.use(BufferedReader::readText)
if (theme == Theme.CUSTOM) {
val bt = if (Color.alpha(bgColor) == 255) bgColor.toRgbaString() else "transparent"
val bb = bgColor.colorToForeground(0.35f)
content =
content
.replace("\$T\$", textColor.toRgbaString())
.replace("\$TT\$", textColor.colorToBackground(0.05f).toRgbaString())
.replace("\$TD\$", textColor.adjustAlpha(0.6f).toRgbaString())
.replace("\$A\$", accentColor.toRgbaString())
.replace("\$AT\$", iconColor.toRgbaString())
.replace("\$B\$", bgColor.toRgbaString())
.replace("\$BT\$", bt)
.replace("\$BBT\$", bb.withAlpha(51).toRgbaString())
.replace("\$O\$", bgColor.withAlpha(255).toRgbaString())
.replace("\$OO\$", bb.withAlpha(255).toRgbaString())
.replace("\$D\$", textColor.adjustAlpha(0.3f).toRgbaString())
.replace("\$TI\$", bb.withAlpha(60).toRgbaString())
.replace("\$C\$", bt)
}
return JsBuilder().css(content).build()
} catch (e: FileNotFoundException) {
L.e(e) { "CssAssets file not found" }
return JsActions.EMPTY
}
}
override fun setTheme(id: Int) {
if (theme.ordinal == id) return
theme = Theme.values[id]
reset()
}
override fun reset() {
injectors.clear()
}
override suspend fun preload() {
withContext(Dispatchers.IO) {
reset()
ThemeCategory.values().forEach { injector(it) }
}
}
}
@Module
@InstallIn(SingletonComponent::class)
interface ThemeProviderModule {
@Binds
@Singleton
fun themeProvider(to: ThemeProviderImpl): ThemeProvider
@Binds @Singleton fun themeProvider(to: ThemeProviderImpl): ThemeProvider
}

View File

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

View File

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

View File

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

View File

@ -31,109 +31,109 @@ import javax.inject.Inject
*/
@Deprecated(level = DeprecationLevel.WARNING, message = "Use pref segments")
class OldPrefs @Inject internal constructor(factory: KPrefFactory) :
KPref("${BuildConfig.APPLICATION_ID}.prefs", factory) {
KPref("${BuildConfig.APPLICATION_ID}.prefs", factory) {
var lastLaunch: Long by kpref("last_launch", -1L)
var lastLaunch: Long by kpref("last_launch", -1L)
var userId: Long by kpref("user_id", -1L)
var userId: Long by kpref("user_id", -1L)
var prevId: Long by kpref("prev_id", -1L)
var prevId: Long by kpref("prev_id", -1L)
var theme: Int by kpref("theme", 0)
var theme: Int by kpref("theme", 0)
var customTextColor: Int by kpref("color_text", 0xffeceff1.toInt())
var customTextColor: Int by kpref("color_text", 0xffeceff1.toInt())
var customAccentColor: Int by kpref("color_accent", 0xff0288d1.toInt())
var customAccentColor: Int by kpref("color_accent", 0xff0288d1.toInt())
var customBackgroundColor: Int by kpref("color_bg", 0xff212121.toInt())
var customBackgroundColor: Int by kpref("color_bg", 0xff212121.toInt())
var customHeaderColor: Int by kpref("color_header", 0xff01579b.toInt())
var customHeaderColor: Int by kpref("color_header", 0xff01579b.toInt())
var customIconColor: Int by kpref("color_icons", 0xffeceff1.toInt())
var customIconColor: Int by kpref("color_icons", 0xffeceff1.toInt())
var exitConfirmation: Boolean by kpref("exit_confirmation", true)
var exitConfirmation: Boolean by kpref("exit_confirmation", true)
var notificationFreq: Long by kpref("notification_freq", 15L)
var notificationFreq: Long by kpref("notification_freq", 15L)
var versionCode: Int by kpref("version_code", -1)
var versionCode: Int by kpref("version_code", -1)
var prevVersionCode: Int by kpref("prev_version_code", -1)
var prevVersionCode: Int by kpref("prev_version_code", -1)
var installDate: Long by kpref("install_date", -1L)
var installDate: Long by kpref("install_date", -1L)
var identifier: Int by kpref("identifier", -1)
var identifier: Int by kpref("identifier", -1)
var tintNavBar: Boolean by kpref("tint_nav_bar", true)
var tintNavBar: Boolean by kpref("tint_nav_bar", true)
var webTextScaling: Int by kpref("web_text_scaling", 100)
var webTextScaling: Int by kpref("web_text_scaling", 100)
var feedSort: Int by kpref("feed_sort", FeedSort.DEFAULT.ordinal)
var feedSort: Int by kpref("feed_sort", FeedSort.DEFAULT.ordinal)
var aggressiveRecents: Boolean by kpref("aggressive_recents", false)
var aggressiveRecents: Boolean by kpref("aggressive_recents", false)
var showComposer: Boolean by kpref("status_composer_feed", true)
var showComposer: Boolean by kpref("status_composer_feed", true)
var showSuggestedFriends: Boolean by kpref("suggested_friends_feed", true)
var showSuggestedFriends: Boolean by kpref("suggested_friends_feed", true)
var showSuggestedGroups: Boolean by kpref("suggested_groups_feed", true)
var showSuggestedGroups: Boolean by kpref("suggested_groups_feed", true)
var showFacebookAds: Boolean by kpref("facebook_ads", false)
var showFacebookAds: Boolean by kpref("facebook_ads", false)
var showStories: Boolean by kpref("show_stories", true)
var showStories: Boolean by kpref("show_stories", true)
var animate: Boolean by kpref("fancy_animations", true)
var animate: Boolean by kpref("fancy_animations", true)
var notificationKeywords: Set<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.
* Verbose is never logged in release builds.
*/
var verboseLogging: Boolean by kpref("verbose_logging", false)
/**
* Despite the naming, this toggle currently only enables debug logging. Verbose is never logged
* in release builds.
*/
var verboseLogging: Boolean by kpref("verbose_logging", false)
var biometricsEnabled: Boolean by kpref("biometrics_enabled", false)
var biometricsEnabled: Boolean by kpref("biometrics_enabled", false)
var overlayEnabled: Boolean by kpref("overlay_enabled", true)
var overlayEnabled: Boolean by kpref("overlay_enabled", true)
var overlayFullScreenSwipe: Boolean by kpref("overlay_full_screen_swipe", true)
var overlayFullScreenSwipe: Boolean by kpref("overlay_full_screen_swipe", true)
var viewpagerSwipe: Boolean by kpref("viewpager_swipe", true)
var viewpagerSwipe: Boolean by kpref("viewpager_swipe", true)
var loadMediaOnMeteredNetwork: Boolean by kpref("media_on_metered_network", true)
var loadMediaOnMeteredNetwork: Boolean by kpref("media_on_metered_network", true)
var debugSettings: Boolean by kpref("debug_settings", false)
var debugSettings: Boolean by kpref("debug_settings", false)
var linksInDefaultApp: Boolean by kpref("link_in_default_app", false)
var linksInDefaultApp: Boolean by kpref("link_in_default_app", false)
var mainActivityLayoutType: Int by kpref("main_activity_layout_type", 0)
var mainActivityLayoutType: Int by kpref("main_activity_layout_type", 0)
var blackMediaBg: Boolean by kpref("black_media_bg", false)
var blackMediaBg: Boolean by kpref("black_media_bg", false)
var autoRefreshFeed: Boolean by kpref("auto_refresh_feed", false)
var autoRefreshFeed: Boolean by kpref("auto_refresh_feed", false)
var showCreateFab: Boolean by kpref("show_create_fab", true)
var showCreateFab: Boolean by kpref("show_create_fab", true)
var fullSizeImage: Boolean by kpref("full_size_image", false)
var fullSizeImage: Boolean by kpref("full_size_image", false)
}

View File

@ -41,92 +41,76 @@ import javax.inject.Inject
import javax.inject.Singleton
/**
* [Prefs] is no longer an actual pref, but we will expose the reset function as it is used elsewhere
* [Prefs] is no longer an actual pref, but we will expose the reset function as it is used
* elsewhere
*/
interface PrefsBase {
fun reset()
fun deleteKeys(vararg keys: String)
fun reset()
fun deleteKeys(vararg keys: String)
}
interface Prefs :
BehaviourPrefs,
CorePrefs,
FeedPrefs,
NotifPrefs,
ThemePrefs,
ShowcasePrefs,
PrefsBase
BehaviourPrefs, CorePrefs, FeedPrefs, NotifPrefs, ThemePrefs, ShowcasePrefs, PrefsBase
class PrefsImpl @Inject internal constructor(
private val behaviourPrefs: BehaviourPrefs,
private val corePrefs: CorePrefs,
private val feedPrefs: FeedPrefs,
private val notifPrefs: NotifPrefs,
private val themePrefs: ThemePrefs,
private val showcasePrefs: ShowcasePrefs
) : Prefs,
BehaviourPrefs by behaviourPrefs,
CorePrefs by corePrefs,
FeedPrefs by feedPrefs,
NotifPrefs by notifPrefs,
ThemePrefs by themePrefs,
ShowcasePrefs by showcasePrefs {
class PrefsImpl
@Inject
internal constructor(
private val behaviourPrefs: BehaviourPrefs,
private val corePrefs: CorePrefs,
private val feedPrefs: FeedPrefs,
private val notifPrefs: NotifPrefs,
private val themePrefs: ThemePrefs,
private val showcasePrefs: ShowcasePrefs
) :
Prefs,
BehaviourPrefs by behaviourPrefs,
CorePrefs by corePrefs,
FeedPrefs by feedPrefs,
NotifPrefs by notifPrefs,
ThemePrefs by themePrefs,
ShowcasePrefs by showcasePrefs {
override fun reset() {
behaviourPrefs.reset()
corePrefs.reset()
feedPrefs.reset()
notifPrefs.reset()
themePrefs.reset()
showcasePrefs.reset()
}
override fun reset() {
behaviourPrefs.reset()
corePrefs.reset()
feedPrefs.reset()
notifPrefs.reset()
themePrefs.reset()
showcasePrefs.reset()
}
override fun deleteKeys(vararg keys: String) {
behaviourPrefs.deleteKeys()
corePrefs.deleteKeys()
feedPrefs.deleteKeys()
notifPrefs.deleteKeys()
themePrefs.deleteKeys()
showcasePrefs.deleteKeys()
}
override fun deleteKeys(vararg keys: String) {
behaviourPrefs.deleteKeys()
corePrefs.deleteKeys()
feedPrefs.deleteKeys()
notifPrefs.deleteKeys()
themePrefs.deleteKeys()
showcasePrefs.deleteKeys()
}
}
@Module
@InstallIn(SingletonComponent::class)
interface PrefModule {
@Binds
@Singleton
fun behaviour(to: BehaviourPrefsImpl): BehaviourPrefs
@Binds @Singleton fun behaviour(to: BehaviourPrefsImpl): BehaviourPrefs
@Binds
@Singleton
fun core(to: CorePrefsImpl): CorePrefs
@Binds @Singleton fun core(to: CorePrefsImpl): CorePrefs
@Binds
@Singleton
fun feed(to: FeedPrefsImpl): FeedPrefs
@Binds @Singleton fun feed(to: FeedPrefsImpl): FeedPrefs
@Binds
@Singleton
fun notif(to: NotifPrefsImpl): NotifPrefs
@Binds @Singleton fun notif(to: NotifPrefsImpl): NotifPrefs
@Binds
@Singleton
fun theme(to: ThemePrefsImpl): ThemePrefs
@Binds @Singleton fun theme(to: ThemePrefsImpl): ThemePrefs
@Binds
@Singleton
fun showcase(to: ShowcasePrefsImpl): ShowcasePrefs
@Binds @Singleton fun showcase(to: ShowcasePrefsImpl): ShowcasePrefs
@Binds
@Singleton
fun prefs(to: PrefsImpl): Prefs
@Binds @Singleton fun prefs(to: PrefsImpl): Prefs
}
@Module
@InstallIn(SingletonComponent::class)
object PrefFactoryModule {
@Provides
@Singleton
fun factory(@ApplicationContext context: Context): KPrefFactory = KPrefFactoryAndroid(context)
@Provides
@Singleton
fun factory(@ApplicationContext context: Context): KPrefFactory = KPrefFactoryAndroid(context)
}

View File

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

View File

@ -24,78 +24,71 @@ import com.pitchedapps.frost.prefs.PrefsBase
import javax.inject.Inject
interface CorePrefs : PrefsBase {
var lastLaunch: Long
var lastLaunch: Long
var userId: Long
var userId: Long
var prevId: Long
var prevId: Long
val frostId: String
val frostId: String
var versionCode: Int
var versionCode: Int
var prevVersionCode: Int
var prevVersionCode: Int
var installDate: Long
var installDate: Long
var identifier: Int
var identifier: Int
/**
* Despite the naming, this toggle currently only enables debug logging.
* Verbose is never logged in release builds.
*/
var verboseLogging: Boolean
/**
* Despite the naming, this toggle currently only enables debug logging. Verbose is never logged
* in release builds.
*/
var verboseLogging: Boolean
var enablePip: Boolean
var enablePip: Boolean
var exitConfirmation: Boolean
var exitConfirmation: Boolean
var animate: Boolean
var animate: Boolean
var messageScrollToBottom: Boolean
var messageScrollToBottom: Boolean
}
class CorePrefsImpl @Inject internal constructor(
factory: KPrefFactory,
oldPrefs: OldPrefs,
class CorePrefsImpl
@Inject
internal constructor(
factory: KPrefFactory,
oldPrefs: OldPrefs,
) : KPref("${BuildConfig.APPLICATION_ID}.prefs.core", factory), CorePrefs {
override var lastLaunch: Long by kpref("last_launch", oldPrefs.lastLaunch /* -1L */)
override var lastLaunch: Long by kpref("last_launch", oldPrefs.lastLaunch /* -1L */)
override var userId: Long by kpref("user_id", oldPrefs.userId /* -1L */)
override var userId: Long by kpref("user_id", oldPrefs.userId /* -1L */)
override var prevId: Long by kpref("prev_id", oldPrefs.prevId /* -1L */)
override var prevId: Long by kpref("prev_id", oldPrefs.prevId /* -1L */)
override val frostId: String
get() = "$installDate-$identifier"
override val frostId: String
get() = "$installDate-$identifier"
override var versionCode: Int by kpref("version_code", oldPrefs.versionCode /* -1 */)
override var versionCode: Int by kpref("version_code", oldPrefs.versionCode /* -1 */)
override var prevVersionCode: Int by kpref(
"prev_version_code",
oldPrefs.prevVersionCode /* -1 */
)
override var prevVersionCode: Int by kpref("prev_version_code", oldPrefs.prevVersionCode /* -1 */)
override var installDate: Long by kpref("install_date", oldPrefs.installDate /* -1L */)
override var installDate: Long by kpref("install_date", oldPrefs.installDate /* -1L */)
override var identifier: Int by kpref("identifier", oldPrefs.identifier /* -1 */)
override var identifier: Int by kpref("identifier", oldPrefs.identifier /* -1 */)
override var verboseLogging: Boolean by kpref(
"verbose_logging",
oldPrefs.verboseLogging /* false */
)
override var verboseLogging: Boolean by
kpref("verbose_logging", oldPrefs.verboseLogging /* false */)
override var enablePip: Boolean by kpref("enable_pip", oldPrefs.enablePip /* true */)
override var enablePip: Boolean by kpref("enable_pip", oldPrefs.enablePip /* true */)
override var exitConfirmation: Boolean by kpref(
"exit_confirmation",
oldPrefs.exitConfirmation /* true */
)
override var exitConfirmation: Boolean by
kpref("exit_confirmation", oldPrefs.exitConfirmation /* true */)
override var animate: Boolean by kpref("fancy_animations", oldPrefs.animate /* true */)
override var animate: Boolean by kpref("fancy_animations", oldPrefs.animate /* true */)
override var messageScrollToBottom: Boolean by kpref(
"message_scroll_to_bottom",
oldPrefs.messageScrollToBottom /* false */
)
override var messageScrollToBottom: Boolean by
kpref("message_scroll_to_bottom", oldPrefs.messageScrollToBottom /* false */)
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -60,283 +60,271 @@ import kotlin.math.abs
private val _40_DP = 40.dpToPx
private val pendingIntentFlagUpdateCurrent: Int
get() = PendingIntent.FLAG_UPDATE_CURRENT or
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) PendingIntent.FLAG_IMMUTABLE else 0
get() =
PendingIntent.FLAG_UPDATE_CURRENT or
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) PendingIntent.FLAG_IMMUTABLE else 0
/**
* Enum to handle notification creations
*/
/** Enum to handle notification creations */
enum class NotificationType(
val channelId: String,
private val overlayContext: OverlayContext,
private val fbItem: FbItem,
private val parser: FrostParser<ParseNotification>,
private val ringtoneProvider: (Prefs) -> String
val channelId: String,
private val overlayContext: OverlayContext,
private val fbItem: FbItem,
private val parser: FrostParser<ParseNotification>,
private val ringtoneProvider: (Prefs) -> String
) {
GENERAL(
NOTIF_CHANNEL_GENERAL,
OverlayContext.NOTIFICATION,
FbItem.NOTIFICATIONS,
NotifParser,
{ it.notificationRingtone }
),
MESSAGE(
NOTIF_CHANNEL_MESSAGES,
OverlayContext.MESSAGE,
FbItem.MESSAGES,
MessageParser,
{ it.messageRingtone }
);
GENERAL(
NOTIF_CHANNEL_GENERAL,
OverlayContext.NOTIFICATION,
FbItem.NOTIFICATIONS,
NotifParser,
{ it.notificationRingtone }
),
private val groupPrefix = "frost_${name.toLowerCase(Locale.CANADA)}"
MESSAGE(
NOTIF_CHANNEL_MESSAGES,
OverlayContext.MESSAGE,
FbItem.MESSAGES,
MessageParser,
{ it.messageRingtone }
);
/** Optional binder to return the request bundle builder */
internal open fun bindRequest(
content: NotificationContent,
cookie: String
): (BaseBundle.() -> Unit)? = null
private val groupPrefix = "frost_${name.toLowerCase(Locale.CANADA)}"
private fun bindRequest(intent: Intent, content: NotificationContent) {
val cookie = content.data.cookie ?: return
val binder = bindRequest(content, cookie) ?: return
val bundle = Bundle()
bundle.binder()
intent.putExtras(bundle)
}
/**
* Optional binder to return the request bundle builder
*/
internal open fun bindRequest(
content: NotificationContent,
cookie: String
): (BaseBundle.() -> Unit)? = null
private fun bindRequest(intent: Intent, content: NotificationContent) {
val cookie = content.data.cookie ?: return
val binder = bindRequest(content, cookie) ?: return
val bundle = Bundle()
bundle.binder()
intent.putExtras(bundle)
/**
* Get unread data from designated parser Display notifications for those after old epoch Save new
* epoch
*
* Returns the number of notifications generated, or -1 if an error occurred
*/
suspend fun fetch(
context: Context,
data: CookieEntity,
prefs: Prefs,
notifDao: NotificationDao
): Int {
val response =
try {
parser.parse(data.cookie)
} catch (ignored: Exception) {
null
}
if (response == null) {
L.v { "$name notification data not found" }
return -1
}
/**
* Get unread data from designated parser
* Display notifications for those after old epoch
* Save new epoch
*
* Returns the number of notifications generated,
* or -1 if an error occurred
*/
suspend fun fetch(
context: Context,
data: CookieEntity,
prefs: Prefs,
notifDao: NotificationDao
): Int {
val response = try {
parser.parse(data.cookie)
} catch (ignored: Exception) {
null
}
if (response == null) {
L.v { "$name notification data not found" }
return -1
}
/**
* Checks that the text doesn't contain any blacklisted keywords
*/
fun validText(text: String?): Boolean {
val t = text ?: return true
return prefs.notificationKeywords.none {
t.contains(it, true)
}
}
val notifContents = response.data.getUnreadNotifications(data).filter { notif ->
validText(notif.title) && validText(notif.text)
}
if (notifContents.isEmpty()) return 0
val userId = data.id
val prevLatestEpoch = notifDao.latestEpoch(userId, channelId)
L.v { "Notif $name prev epoch $prevLatestEpoch" }
if (!notifDao.saveNotifications(channelId, notifContents)) {
L.d { "Skip notifs for $name as saving failed" }
return -1
}
if (prevLatestEpoch == -1L && !BuildConfig.DEBUG) {
L.d { "Skipping first notification fetch" }
return 0 // do not notify the first time
}
val newNotifContents = notifContents.filter { it.timestamp > prevLatestEpoch }
if (newNotifContents.isEmpty()) {
L.d { "No new notifs found for $name" }
return 0
}
L.d { "${newNotifContents.size} new notifs found for $name" }
val notifs = newNotifContents.map { createNotification(context, it) }
frostEvent("Notifications", "Type" to name, "Count" to notifs.size)
if (notifs.size > 1)
summaryNotification(context, userId, notifs.size).notify(context)
val ringtone = ringtoneProvider(prefs)
notifs.forEachIndexed { i, notif ->
// Ring at most twice
notif.withAlert(context, i < 2, ringtone, prefs).notify(context)
}
return notifs.size
/** Checks that the text doesn't contain any blacklisted keywords */
fun validText(text: String?): Boolean {
val t = text ?: return true
return prefs.notificationKeywords.none { t.contains(it, true) }
}
fun debugNotification(context: Context, data: CookieEntity) {
val content = NotificationContent(
data,
System.currentTimeMillis(),
"https://github.com/AllanWang/Frost-for-Facebook",
"Debug Notif",
"Test 123",
System.currentTimeMillis() / 1000,
"https://www.iconexperience.com/_img/v_collection_png/256x256/shadow/dog.png",
false
)
createNotification(context, content).notify(context)
val notifContents =
response.data.getUnreadNotifications(data).filter { notif ->
validText(notif.title) && validText(notif.text)
}
if (notifContents.isEmpty()) return 0
val userId = data.id
val prevLatestEpoch = notifDao.latestEpoch(userId, channelId)
L.v { "Notif $name prev epoch $prevLatestEpoch" }
if (!notifDao.saveNotifications(channelId, notifContents)) {
L.d { "Skip notifs for $name as saving failed" }
return -1
}
/**
* Attach content related data to an intent
*/
fun putContentExtra(intent: Intent, content: NotificationContent): Intent {
// We will show the notification page for dependent urls. We can trigger a click next time
intent.data =
Uri.parse(if (content.href.isIndependent) content.href else FbItem.NOTIFICATIONS.url)
bindRequest(intent, content)
return intent
if (prevLatestEpoch == -1L && !BuildConfig.DEBUG) {
L.d { "Skipping first notification fetch" }
return 0 // do not notify the first time
}
/**
* Create a generic content for the provided type and user id.
* No content related data is added
*/
fun createCommonIntent(context: Context, userId: Long): Intent {
val intent = Intent(context, FrostWebActivity::class.java)
intent.putExtra(ARG_USER_ID, userId)
overlayContext.put(intent)
return intent
val newNotifContents = notifContents.filter { it.timestamp > prevLatestEpoch }
if (newNotifContents.isEmpty()) {
L.d { "No new notifs found for $name" }
return 0
}
/**
* Create and submit a new notification with the given [content]
*/
private fun createNotification(
context: Context,
content: NotificationContent
): FrostNotification =
with(content) {
val intent = createCommonIntent(context, content.data.id)
putContentExtra(intent, content)
val group = "${groupPrefix}_${data.id}"
val pendingIntent =
PendingIntent.getActivity(context, 0, intent, pendingIntentFlagUpdateCurrent)
val notifBuilder = context.frostNotification(channelId)
.setContentTitle(title ?: context.string(R.string.frost_name))
.setContentText(text)
.setContentIntent(pendingIntent)
.setCategory(Notification.CATEGORY_SOCIAL)
.setSubText(data.name)
.setGroup(group)
L.d { "${newNotifContents.size} new notifs found for $name" }
if (timestamp != -1L) notifBuilder.setWhen(timestamp * 1000)
L.v { "Notif load $content" }
val notifs = newNotifContents.map { createNotification(context, it) }
if (profileUrl != null) {
try {
val profileImg = GlideApp.with(context)
.asBitmap()
.load(profileUrl)
.transform(FrostGlide.circleCrop)
.submit(_40_DP, _40_DP)
.get()
notifBuilder.setLargeIcon(profileImg)
} catch (e: Exception) {
L.e { "Failed to get image $profileUrl" }
}
}
frostEvent("Notifications", "Type" to name, "Count" to notifs.size)
if (notifs.size > 1) summaryNotification(context, userId, notifs.size).notify(context)
val ringtone = ringtoneProvider(prefs)
notifs.forEachIndexed { i, notif ->
// Ring at most twice
notif.withAlert(context, i < 2, ringtone, prefs).notify(context)
}
return notifs.size
}
FrostNotification(group, notifId, notifBuilder)
fun debugNotification(context: Context, data: CookieEntity) {
val content =
NotificationContent(
data,
System.currentTimeMillis(),
"https://github.com/AllanWang/Frost-for-Facebook",
"Debug Notif",
"Test 123",
System.currentTimeMillis() / 1000,
"https://www.iconexperience.com/_img/v_collection_png/256x256/shadow/dog.png",
false
)
createNotification(context, content).notify(context)
}
/** Attach content related data to an intent */
fun putContentExtra(intent: Intent, content: NotificationContent): Intent {
// We will show the notification page for dependent urls. We can trigger a click next time
intent.data =
Uri.parse(if (content.href.isIndependent) content.href else FbItem.NOTIFICATIONS.url)
bindRequest(intent, content)
return intent
}
/**
* Create a generic content for the provided type and user id. No content related data is added
*/
fun createCommonIntent(context: Context, userId: Long): Intent {
val intent = Intent(context, FrostWebActivity::class.java)
intent.putExtra(ARG_USER_ID, userId)
overlayContext.put(intent)
return intent
}
/** Create and submit a new notification with the given [content] */
private fun createNotification(
context: Context,
content: NotificationContent
): FrostNotification =
with(content) {
val intent = createCommonIntent(context, content.data.id)
putContentExtra(intent, content)
val group = "${groupPrefix}_${data.id}"
val pendingIntent =
PendingIntent.getActivity(context, 0, intent, pendingIntentFlagUpdateCurrent)
val notifBuilder =
context
.frostNotification(channelId)
.setContentTitle(title ?: context.string(R.string.frost_name))
.setContentText(text)
.setContentIntent(pendingIntent)
.setCategory(Notification.CATEGORY_SOCIAL)
.setSubText(data.name)
.setGroup(group)
if (timestamp != -1L) notifBuilder.setWhen(timestamp * 1000)
L.v { "Notif load $content" }
if (profileUrl != null) {
try {
val profileImg =
GlideApp.with(context)
.asBitmap()
.load(profileUrl)
.transform(FrostGlide.circleCrop)
.submit(_40_DP, _40_DP)
.get()
notifBuilder.setLargeIcon(profileImg)
} catch (e: Exception) {
L.e { "Failed to get image $profileUrl" }
}
}
/**
* Create a summary notification to wrap the previous ones
* This will always produce sound, vibration, and lights based on preferences
* and will only show if we have at least 2 notifications
*/
private fun summaryNotification(context: Context, userId: Long, count: Int): FrostNotification {
val intent = createCommonIntent(context, userId)
intent.data = Uri.parse(fbItem.url)
val group = "${groupPrefix}_$userId"
val pendingIntent =
PendingIntent.getActivity(context, 0, intent, pendingIntentFlagUpdateCurrent)
val notifBuilder = context.frostNotification(channelId)
.setContentTitle(context.string(R.string.frost_name))
.setContentText("$count ${context.string(fbItem.titleId)}")
.setGroup(group)
.setGroupSummary(true)
.setContentIntent(pendingIntent)
.setCategory(Notification.CATEGORY_SOCIAL)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
notifBuilder.setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_CHILDREN)
}
return FrostNotification(group, 1, notifBuilder)
FrostNotification(group, notifId, notifBuilder)
}
/**
* Create a summary notification to wrap the previous ones This will always produce sound,
* vibration, and lights based on preferences and will only show if we have at least 2
* notifications
*/
private fun summaryNotification(context: Context, userId: Long, count: Int): FrostNotification {
val intent = createCommonIntent(context, userId)
intent.data = Uri.parse(fbItem.url)
val group = "${groupPrefix}_$userId"
val pendingIntent =
PendingIntent.getActivity(context, 0, intent, pendingIntentFlagUpdateCurrent)
val notifBuilder =
context
.frostNotification(channelId)
.setContentTitle(context.string(R.string.frost_name))
.setContentText("$count ${context.string(fbItem.titleId)}")
.setGroup(group)
.setGroupSummary(true)
.setContentIntent(pendingIntent)
.setCategory(Notification.CATEGORY_SOCIAL)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
notifBuilder.setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_CHILDREN)
}
return FrostNotification(group, 1, notifBuilder)
}
}
/**
* Notification data holder
*/
/** Notification data holder */
data class NotificationContent(
// TODO replace data with userId?
val data: CookieEntity,
val id: Long,
val href: String,
val title: String? = null, // defaults to frost title
val text: String,
val timestamp: Long,
val profileUrl: String?,
val unread: Boolean
// TODO replace data with userId?
val data: CookieEntity,
val id: Long,
val href: String,
val title: String? = null, // defaults to frost title
val text: String,
val timestamp: Long,
val profileUrl: String?,
val unread: Boolean
) {
val notifId = abs(id.toInt())
val notifId = abs(id.toInt())
}
/**
* Wrapper for a complete notification builder and identifier
* which can be immediately notified when given a [Context]
* Wrapper for a complete notification builder and identifier which can be immediately notified when
* given a [Context]
*/
data class FrostNotification(
private val tag: String,
private val id: Int,
val notif: NotificationCompat.Builder
private val tag: String,
private val id: Int,
val notif: NotificationCompat.Builder
) {
fun withAlert(
context: Context,
enable: Boolean,
ringtone: String,
prefs: Prefs
): FrostNotification {
notif.setFrostAlert(context, enable, ringtone, prefs)
return this
}
fun withAlert(
context: Context,
enable: Boolean,
ringtone: String,
prefs: Prefs
): FrostNotification {
notif.setFrostAlert(context, enable, ringtone, prefs)
return this
}
fun notify(context: Context) =
NotificationManagerCompat.from(context).notify(tag, id, notif.build())
fun notify(context: Context) =
NotificationManagerCompat.from(context).notify(tag, id, notif.build())
}
fun Context.scheduleNotificationsFromPrefs(prefs: Prefs): Boolean {
val shouldSchedule = prefs.hasNotifications
return if (shouldSchedule) scheduleNotifications(prefs.notificationFreq)
else scheduleNotifications(-1)
val shouldSchedule = prefs.hasNotifications
return if (shouldSchedule) scheduleNotifications(prefs.notificationFreq)
else scheduleNotifications(-1)
}
fun Context.scheduleNotifications(minutes: Long): Boolean =
scheduleJob<NotificationService>(NOTIFICATION_PERIODIC_JOB, minutes)
scheduleJob<NotificationService>(NOTIFICATION_PERIODIC_JOB, minutes)
fun Context.fetchNotifications(): Boolean =
fetchJob<NotificationService>(NOTIFICATION_JOB_NOW)
fun Context.fetchNotifications(): Boolean = 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.widgets.NotificationWidget
import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.coroutines.yield
import javax.inject.Inject
/**
* Created by Allan Wang on 2017-06-14.
*
* Service to manage notifications
* Will periodically check through all accounts in the db and send notifications when appropriate
* Service to manage notifications Will periodically check through all accounts in the db and send
* notifications when appropriate
*
* All fetching is done through parsers
*/
@AndroidEntryPoint
class NotificationService : BaseJobService() {
@Inject
lateinit var prefs: Prefs
@Inject lateinit var prefs: Prefs
@Inject
lateinit var notifDao: NotificationDao
@Inject lateinit var notifDao: NotificationDao
@Inject
lateinit var cookieDao: CookieDao
@Inject lateinit var cookieDao: CookieDao
override fun onStopJob(params: JobParameters?): Boolean {
super.onStopJob(params)
prepareFinish(true)
return false
override fun onStopJob(params: JobParameters?): Boolean {
super.onStopJob(params)
prepareFinish(true)
return false
}
private var preparedFinish = false
private fun prepareFinish(abrupt: Boolean) {
if (preparedFinish) return
preparedFinish = true
val time = System.currentTimeMillis() - startTime
L.i {
"Notification service has ${if (abrupt) "finished abruptly" else "finished"} in $time ms"
}
frostEvent(
"NotificationTime",
"Type" to (if (abrupt) "Service force stop" else "Service"),
"IM Included" to prefs.notificationsInstantMessages,
"Duration" to time
)
}
override fun onStartJob(params: JobParameters?): Boolean {
super.onStartJob(params)
L.i { "Fetching notifications" }
launch {
try {
sendNotifications(params)
} finally {
if (!isActive) prepareFinish(false)
jobFinished(params, false)
}
}
return true
}
private suspend fun sendNotifications(params: JobParameters?): Unit =
withContext(Dispatchers.Default) {
val currentId = prefs.userId
val cookies = cookieDao.selectAll()
yield()
val jobId = params?.extras?.getInt(NOTIFICATION_PARAM_ID, -1) ?: -1
var notifCount = 0
for (cookie in cookies) {
yield()
val current = cookie.id == currentId
if (prefs.notificationsGeneral && (current || prefs.notificationAllAccounts))
notifCount += fetch(jobId, NotificationType.GENERAL, cookie)
if (prefs.notificationsInstantMessages && (current || prefs.notificationsImAllAccounts))
notifCount += fetch(jobId, NotificationType.MESSAGE, cookie)
}
L.i { "Sent $notifCount notifications" }
if (notifCount == 0 && jobId == NOTIFICATION_JOB_NOW)
generalNotification(665, R.string.no_new_notifications, BuildConfig.DEBUG)
if (notifCount > 0) {
NotificationWidget.forceUpdate(this@NotificationService)
}
}
private var preparedFinish = false
private fun prepareFinish(abrupt: Boolean) {
if (preparedFinish)
return
preparedFinish = true
val time = System.currentTimeMillis() - startTime
L.i { "Notification service has ${if (abrupt) "finished abruptly" else "finished"} in $time ms" }
frostEvent(
"NotificationTime",
"Type" to (if (abrupt) "Service force stop" else "Service"),
"IM Included" to prefs.notificationsInstantMessages,
"Duration" to time
)
/**
* Implemented fetch to also notify when an error occurs Also normalized the output to return the
* number of notifications received
*/
private suspend fun fetch(jobId: Int, type: NotificationType, cookie: CookieEntity): Int {
val count = type.fetch(this, cookie, prefs, notifDao)
if (count < 0) {
if (jobId == NOTIFICATION_JOB_NOW)
generalNotification(666, R.string.error_notification, BuildConfig.DEBUG)
return 0
}
return count
}
override fun onStartJob(params: JobParameters?): Boolean {
super.onStartJob(params)
L.i { "Fetching notifications" }
launch {
try {
sendNotifications(params)
} finally {
if (!isActive)
prepareFinish(false)
jobFinished(params, false)
}
}
return true
}
private fun logNotif(text: String): NotificationContent? {
L.eThrow("NotificationService: $text")
return null
}
private suspend fun sendNotifications(params: JobParameters?): Unit =
withContext(Dispatchers.Default) {
val currentId = prefs.userId
val cookies = cookieDao.selectAll()
yield()
val jobId = params?.extras?.getInt(NOTIFICATION_PARAM_ID, -1) ?: -1
var notifCount = 0
for (cookie in cookies) {
yield()
val current = cookie.id == currentId
if (prefs.notificationsGeneral &&
(current || prefs.notificationAllAccounts)
)
notifCount += fetch(jobId, NotificationType.GENERAL, cookie)
if (prefs.notificationsInstantMessages &&
(current || prefs.notificationsImAllAccounts)
)
notifCount += fetch(jobId, NotificationType.MESSAGE, cookie)
}
L.i { "Sent $notifCount notifications" }
if (notifCount == 0 && jobId == NOTIFICATION_JOB_NOW)
generalNotification(665, R.string.no_new_notifications, BuildConfig.DEBUG)
if (notifCount > 0) {
NotificationWidget.forceUpdate(this@NotificationService)
}
}
/**
* Implemented fetch to also notify when an error occurs
* Also normalized the output to return the number of notifications received
*/
private suspend fun fetch(jobId: Int, type: NotificationType, cookie: CookieEntity): Int {
val count = type.fetch(this, cookie, prefs, notifDao)
if (count < 0) {
if (jobId == NOTIFICATION_JOB_NOW)
generalNotification(666, R.string.error_notification, BuildConfig.DEBUG)
return 0
}
return count
}
private fun logNotif(text: String): NotificationContent? {
L.eThrow("NotificationService: $text")
return null
}
private fun generalNotification(id: Int, textRes: Int, withDefaults: Boolean) {
val notifBuilder = frostNotification(NOTIF_CHANNEL_GENERAL)
.setFrostAlert(this, withDefaults, prefs.notificationRingtone, prefs)
.setContentTitle(string(R.string.frost_name))
.setContentText(string(textRes))
NotificationManagerCompat.from(this).notify(id, notifBuilder.build())
}
private fun generalNotification(id: Int, textRes: Int, withDefaults: Boolean) {
val notifBuilder =
frostNotification(NOTIF_CHANNEL_GENERAL)
.setFrostAlert(this, withDefaults, prefs.notificationRingtone, prefs)
.setContentTitle(string(R.string.frost_name))
.setContentText(string(textRes))
NotificationManagerCompat.from(this).notify(id, notifBuilder.build())
}
}

View File

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

View File

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

View File

@ -35,173 +35,170 @@ import com.pitchedapps.frost.utils.frostSnackbar
import com.pitchedapps.frost.utils.launchTabCustomizerActivity
import com.pitchedapps.frost.views.KPrefTextSeekbar
/**
* Created by Allan Wang on 2017-06-29.
*/
/** Created by Allan Wang on 2017-06-29. */
fun SettingsActivity.getAppearancePrefs(): KPrefAdapterBuilder.() -> Unit = {
header(R.string.theme_customization)
header(R.string.theme_customization)
text(R.string.theme, prefs::theme, { themeProvider.setTheme(it) }) {
onClick = {
materialDialog {
title(R.string.theme)
listItemsSingleChoice(
items = Theme.values().map { string(it.textRes) },
initialSelection = item.pref
) { _, index, _ ->
if (item.pref != index) {
item.pref = index
shouldRestartMain()
reload()
activityThemer.setFrostTheme(forceTransparent = true)
themeExterior()
invalidateOptionsMenu()
frostEvent("Theme", "Count" to Theme(index).name)
}
}
}
}
textGetter = {
string(Theme(it).textRes)
}
}
fun KPrefColorPicker.KPrefColorContract.dependsOnCustom() {
enabler = themeProvider::isCustomTheme
onDisabledClick = { frostSnackbar(R.string.requires_custom_theme, themeProvider) }
allowCustom = true
}
fun invalidateCustomTheme() {
themeProvider.reset()
}
colorPicker(
R.string.text_color, prefs::customTextColor,
{
prefs.customTextColor = it
reload()
invalidateCustomTheme()
text(R.string.theme, prefs::theme, { themeProvider.setTheme(it) }) {
onClick = {
materialDialog {
title(R.string.theme)
listItemsSingleChoice(
items = Theme.values().map { string(it.textRes) },
initialSelection = item.pref
) { _, index, _ ->
if (item.pref != index) {
item.pref = index
shouldRestartMain()
}
) {
dependsOnCustom()
allowCustomAlpha = false
}
colorPicker(
R.string.accent_color, prefs::customAccentColor,
{
prefs.customAccentColor = it
reload()
invalidateCustomTheme()
shouldRestartMain()
}
) {
dependsOnCustom()
allowCustomAlpha = false
}
colorPicker(
R.string.background_color, prefs::customBackgroundColor,
{
prefs.customBackgroundColor = it
bgCanvas.ripple(it, duration = 500L)
invalidateCustomTheme()
activityThemer.setFrostTheme(forceTransparent = true)
shouldRestartMain()
}
) {
dependsOnCustom()
allowCustomAlpha = true
}
colorPicker(
R.string.header_color, prefs::customHeaderColor,
{
prefs.customHeaderColor = it
frostNavigationBar(prefs, themeProvider)
toolbarCanvas.ripple(it, RippleCanvas.MIDDLE, RippleCanvas.END, duration = 500L)
reload()
shouldRestartMain()
}
) {
dependsOnCustom()
allowCustomAlpha = true
}
colorPicker(
R.string.icon_color, prefs::customIconColor,
{
prefs.customIconColor = it
themeExterior()
invalidateOptionsMenu()
frostEvent("Theme", "Count" to Theme(index).name)
}
}
}
}
textGetter = { string(Theme(it).textRes) }
}
fun KPrefColorPicker.KPrefColorContract.dependsOnCustom() {
enabler = themeProvider::isCustomTheme
onDisabledClick = { frostSnackbar(R.string.requires_custom_theme, themeProvider) }
allowCustom = true
}
fun invalidateCustomTheme() {
themeProvider.reset()
}
colorPicker(
R.string.text_color,
prefs::customTextColor,
{
prefs.customTextColor = it
reload()
invalidateCustomTheme()
shouldRestartMain()
}
) {
dependsOnCustom()
allowCustomAlpha = false
}
colorPicker(
R.string.accent_color,
prefs::customAccentColor,
{
prefs.customAccentColor = it
reload()
invalidateCustomTheme()
shouldRestartMain()
}
) {
dependsOnCustom()
allowCustomAlpha = false
}
colorPicker(
R.string.background_color,
prefs::customBackgroundColor,
{
prefs.customBackgroundColor = it
bgCanvas.ripple(it, duration = 500L)
invalidateCustomTheme()
activityThemer.setFrostTheme(forceTransparent = true)
shouldRestartMain()
}
) {
dependsOnCustom()
allowCustomAlpha = true
}
colorPicker(
R.string.header_color,
prefs::customHeaderColor,
{
prefs.customHeaderColor = it
frostNavigationBar(prefs, themeProvider)
toolbarCanvas.ripple(it, RippleCanvas.MIDDLE, RippleCanvas.END, duration = 500L)
reload()
shouldRestartMain()
}
) {
dependsOnCustom()
allowCustomAlpha = true
}
colorPicker(
R.string.icon_color,
prefs::customIconColor,
{
prefs.customIconColor = it
invalidateOptionsMenu()
shouldRestartMain()
}
) {
dependsOnCustom()
allowCustomAlpha = false
}
header(R.string.global_customization)
text(
R.string.main_activity_layout,
prefs::mainActivityLayoutType,
{ prefs.mainActivityLayoutType = it }
) {
textGetter = { string(prefs.mainActivityLayout.titleRes) }
onClick = {
materialDialog {
title(R.string.main_activity_layout_desc)
listItemsSingleChoice(
items = MainActivityLayout.values.map { string(it.titleRes) },
initialSelection = item.pref
) { _, index, _ ->
if (item.pref != index) {
item.pref = index
shouldRestartMain()
frostEvent("Main Layout", "Type" to MainActivityLayout(index).name)
}
}
) {
dependsOnCustom()
allowCustomAlpha = false
}
}
}
header(R.string.global_customization)
plainText(R.string.main_tabs) {
descRes = R.string.main_tabs_desc
onClick = { launchTabCustomizerActivity() }
}
text(
R.string.main_activity_layout,
prefs::mainActivityLayoutType,
{ prefs.mainActivityLayoutType = it }
) {
textGetter = { string(prefs.mainActivityLayout.titleRes) }
onClick = {
materialDialog {
title(R.string.main_activity_layout_desc)
listItemsSingleChoice(
items = MainActivityLayout.values.map { string(it.titleRes) },
initialSelection = item.pref
) { _, index, _ ->
if (item.pref != index) {
item.pref = index
shouldRestartMain()
frostEvent("Main Layout", "Type" to MainActivityLayout(index).name)
}
}
}
}
checkbox(
R.string.tint_nav,
prefs::tintNavBar,
{
prefs.tintNavBar = it
frostNavigationBar(prefs, themeProvider)
setFrostResult(REQUEST_NAV)
}
) {
descRes = R.string.tint_nav_desc
}
plainText(R.string.main_tabs) {
descRes = R.string.main_tabs_desc
onClick = { launchTabCustomizerActivity() }
}
checkbox(
R.string.tint_nav, prefs::tintNavBar,
{
prefs.tintNavBar = it
frostNavigationBar(prefs, themeProvider)
setFrostResult(REQUEST_NAV)
}
) {
descRes = R.string.tint_nav_desc
}
list.add(
KPrefTextSeekbar(
KPrefSeekbar.KPrefSeekbarBuilder(
globalOptions,
R.string.web_text_scaling, prefs::webTextScaling
) {
prefs.webTextScaling = it
setFrostResult(REQUEST_TEXT_ZOOM)
}
)
list.add(
KPrefTextSeekbar(
KPrefSeekbar.KPrefSeekbarBuilder(
globalOptions,
R.string.web_text_scaling,
prefs::webTextScaling
) {
prefs.webTextScaling = it
setFrostResult(REQUEST_TEXT_ZOOM)
}
)
)
checkbox(
R.string.enforce_black_media_bg, prefs::blackMediaBg,
{
prefs.blackMediaBg = it
}
) {
descRes = R.string.enforce_black_media_bg_desc
}
checkbox(R.string.enforce_black_media_bg, prefs::blackMediaBg, { prefs.blackMediaBg = it }) {
descRes = R.string.enforce_black_media_bg_desc
}
}

View File

@ -20,76 +20,86 @@ import ca.allanwang.kau.kpref.activity.KPrefAdapterBuilder
import com.pitchedapps.frost.R
import com.pitchedapps.frost.activities.SettingsActivity
/**
* Created by Allan Wang on 2017-06-30.
*/
/** Created by Allan Wang on 2017-06-30. */
fun SettingsActivity.getBehaviourPrefs(): KPrefAdapterBuilder.() -> Unit = {
checkbox(R.string.auto_refresh_feed, prefs::autoRefreshFeed, { prefs.autoRefreshFeed = it }) {
descRes = R.string.auto_refresh_feed_desc
}
checkbox(R.string.auto_refresh_feed, prefs::autoRefreshFeed, { prefs.autoRefreshFeed = it }) {
descRes = R.string.auto_refresh_feed_desc
checkbox(
R.string.fancy_animations,
prefs::animate,
{
prefs.animate = it
animate = it
}
) {
descRes = R.string.fancy_animations_desc
}
checkbox(R.string.fancy_animations, prefs::animate, { prefs.animate = it; animate = it }) {
descRes = R.string.fancy_animations_desc
checkbox(
R.string.overlay_swipe,
prefs::overlayEnabled,
{
prefs.overlayEnabled = it
shouldRefreshMain()
}
) {
descRes = R.string.overlay_swipe_desc
}
checkbox(
R.string.overlay_swipe,
prefs::overlayEnabled,
{ prefs.overlayEnabled = it; shouldRefreshMain() }
) {
descRes = R.string.overlay_swipe_desc
checkbox(
R.string.overlay_full_screen_swipe,
prefs::overlayFullScreenSwipe,
{ prefs.overlayFullScreenSwipe = it }
) {
descRes = R.string.overlay_full_screen_swipe_desc
}
checkbox(
R.string.open_links_in_default,
prefs::linksInDefaultApp,
{ prefs.linksInDefaultApp = it }
) {
descRes = R.string.open_links_in_default_desc
}
checkbox(R.string.viewpager_swipe, prefs::viewpagerSwipe, { prefs.viewpagerSwipe = it }) {
descRes = R.string.viewpager_swipe_desc
}
checkbox(
R.string.force_message_bottom,
prefs::messageScrollToBottom,
{ prefs.messageScrollToBottom = it }
) {
descRes = R.string.force_message_bottom_desc
}
checkbox(
R.string.auto_expand_text_box,
prefs::autoExpandTextBox,
{
prefs.autoExpandTextBox = it
shouldRefreshMain()
}
) {
descRes = R.string.auto_expand_text_box_desc
}
checkbox(
R.string.overlay_full_screen_swipe,
prefs::overlayFullScreenSwipe,
{ prefs.overlayFullScreenSwipe = it }
) {
descRes = R.string.overlay_full_screen_swipe_desc
}
checkbox(R.string.enable_pip, prefs::enablePip, { prefs.enablePip = it }) {
descRes = R.string.enable_pip_desc
}
checkbox(
R.string.open_links_in_default,
prefs::linksInDefaultApp,
{ prefs.linksInDefaultApp = it }
) {
descRes = R.string.open_links_in_default_desc
}
// Not available for desktop user agent for now
// plainText(R.string.autoplay_settings) {
// descRes = R.string.autoplay_settings_desc
// onClick = {
// launchWebOverlay("${FB_URL_BASE}settings/videos/")
// }
// }
checkbox(R.string.viewpager_swipe, prefs::viewpagerSwipe, { prefs.viewpagerSwipe = it }) {
descRes = R.string.viewpager_swipe_desc
}
checkbox(
R.string.force_message_bottom,
prefs::messageScrollToBottom,
{ prefs.messageScrollToBottom = it }
) {
descRes = R.string.force_message_bottom_desc
}
checkbox(
R.string.auto_expand_text_box,
prefs::autoExpandTextBox,
{ prefs.autoExpandTextBox = it; shouldRefreshMain() }
) {
descRes = R.string.auto_expand_text_box_desc
}
checkbox(R.string.enable_pip, prefs::enablePip, { prefs.enablePip = it }) {
descRes = R.string.enable_pip_desc
}
// Not available for desktop user agent for now
// plainText(R.string.autoplay_settings) {
// descRes = R.string.autoplay_settings_desc
// onClick = {
// launchWebOverlay("${FB_URL_BASE}settings/videos/")
// }
// }
checkbox(R.string.exit_confirmation, prefs::exitConfirmation, { prefs.exitConfirmation = it }) {
descRes = R.string.exit_confirmation_desc
}
checkbox(R.string.exit_confirmation, prefs::exitConfirmation, { prefs.exitConfirmation = it }) {
descRes = R.string.exit_confirmation_desc
}
}

View File

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

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