aboutsummaryrefslogtreecommitdiff
path: root/app
diff options
context:
space:
mode:
Diffstat (limited to 'app')
-rw-r--r--app/build.gradle1
-rw-r--r--app/src/main/assets/css/core/_core_bg.scss7
-rw-r--r--app/src/main/assets/css/core/_core_text.scss2
-rw-r--r--app/src/main/assets/css/core/core.css6
-rw-r--r--app/src/main/assets/css/themes/custom.css6
-rw-r--r--app/src/main/assets/css/themes/material_amoled.css6
-rw-r--r--app/src/main/assets/css/themes/material_dark.css6
-rw-r--r--app/src/main/assets/css/themes/material_glass.css6
-rw-r--r--app/src/main/assets/css/themes/material_light.css6
-rw-r--r--app/src/main/kotlin/com/pitchedapps/frost/activities/BaseMainActivity.kt10
-rw-r--r--app/src/main/kotlin/com/pitchedapps/frost/activities/LoginActivity.kt55
-rw-r--r--app/src/main/kotlin/com/pitchedapps/frost/contracts/ActivityContract.kt1
-rw-r--r--app/src/main/kotlin/com/pitchedapps/frost/facebook/FbRegex.kt11
-rw-r--r--app/src/main/kotlin/com/pitchedapps/frost/facebook/FbRequest.kt57
-rw-r--r--app/src/main/kotlin/com/pitchedapps/frost/fragments/FragmentBase.kt17
-rw-r--r--app/src/main/kotlin/com/pitchedapps/frost/fragments/FragmentContract.kt1
-rw-r--r--app/src/main/kotlin/com/pitchedapps/frost/parsers/FrostParser.kt124
-rw-r--r--app/src/main/kotlin/com/pitchedapps/frost/parsers/MessageParser.kt90
-rw-r--r--app/src/main/kotlin/com/pitchedapps/frost/parsers/NotifParser.kt92
-rw-r--r--app/src/main/kotlin/com/pitchedapps/frost/parsers/SearchParser.kt59
-rw-r--r--app/src/main/kotlin/com/pitchedapps/frost/services/FrostNotifications.kt79
-rw-r--r--app/src/main/kotlin/com/pitchedapps/frost/services/NotificationService.kt107
-rw-r--r--app/src/main/kotlin/com/pitchedapps/frost/settings/Notifications.kt26
-rw-r--r--app/src/main/kotlin/com/pitchedapps/frost/web/LoginWebView.kt7
-rw-r--r--app/src/main/res/xml/frost_changelog.xml9
-rw-r--r--app/src/test/kotlin/com/pitchedapps/frost/facebook/FbParseTest.kt46
-rw-r--r--app/src/test/kotlin/com/pitchedapps/frost/facebook/FbRegexTest.kt41
-rw-r--r--app/src/test/kotlin/com/pitchedapps/frost/facebook/FbRequestTest.kt45
-rw-r--r--app/src/test/kotlin/com/pitchedapps/frost/internal/Internal.kt53
-rw-r--r--app/src/test/kotlin/com/pitchedapps/frost/parsers/MessageParserTest.kt4
-rw-r--r--app/src/test/kotlin/com/pitchedapps/frost/parsers/ParserTestHelper.kt2
31 files changed, 654 insertions, 328 deletions
diff --git a/app/build.gradle b/app/build.gradle
index e09740dd..1c06cee9 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -142,6 +142,7 @@ dependencies {
androidTestImplementation "org.jetbrains.kotlin:kotlin-test-junit:${KOTLIN}"
androidTestImplementation "com.android.support.test:rules:${TEST_RULE}"
testImplementation "org.jetbrains.kotlin:kotlin-test-junit:${KOTLIN}"
+ testImplementation "org.jetbrains.kotlin:kotlin-reflect:${KOTLIN}"
testImplementation "junit:junit:${JUNIT}"
implementation "org.jetbrains.kotlin:kotlin-stdlib:${KOTLIN}"
diff --git a/app/src/main/assets/css/core/_core_bg.scss b/app/src/main/assets/css/core/_core_bg.scss
index ba92dd1c..9fe90443 100644
--- a/app/src/main/assets/css/core/_core_bg.scss
+++ b/app/src/main/assets/css/core/_core_bg.scss
@@ -4,7 +4,7 @@
body, #root, #header, [style*="background-color"], ._1upc, input, ._2f9r, ._59e9, ._5pz4, ._5lp4,
._5lp5, .container, .subpage, ._5n_f, #static_templates, ._22_8, ._1t4h, ._uoq,
-._6-l ._2us7, ._6-l ._6-p, ._333v, div.sharerSelector,
+._6-l ._2us7, ._6-l ._6-p, ._333v, div.sharerSelector, ._529j,
._1g05, .acy, ._51-g, ._533c, ._ib-, .sharerAttachmentEmpty, .sharerBottomWrapper,
._3bg5 ._56do, ._5hfh, ._52e-, .mQuestionsPollResultsBar, ._5hoc, ._5oxw, ._32_4, ._1hiz,
.tlBody, #timelineBody, .timelineX, .timeline, .feed, .tlPrelude, .tlFeedPlaceholder, ._4_d0,
@@ -76,4 +76,9 @@ button:not([style*=image]), button::before, .touch ._56bt, ._56be::before, .btnS
._2zh4::before, ._2ip_ ._2zh4::before, ._2ip_ ._15kk::before, ._2ip_ ._15kk + ._4u3j::before,
._58a0:before, ._43mh::before, ._43mh::after, ._1_-1::before, ._1kmv:after, ._1_ac:before {
background: $divider !important;
+}
+
+//fab
+button ._v89 ._54k8._1fl1 {
+ background: $accent !important;
} \ No newline at end of file
diff --git a/app/src/main/assets/css/core/_core_text.scss b/app/src/main/assets/css/core/_core_text.scss
index 0e858102..bc4a4dc1 100644
--- a/app/src/main/assets/css/core/_core_text.scss
+++ b/app/src/main/assets/css/core/_core_text.scss
@@ -6,7 +6,7 @@
._1lf4, ._1hiz, ._xod, ._5ag5,
._43mh, .touch .btn, p, span, .fcg, button, ._52j9, ._52jb, ._52ja, ._5j35,
._rnk, ._24u0, ._1g06, ._14ye, .fcb, ._56cz._56c_, ._1gk_, ._55fj, ._45fu,
-._18qg, ._1_ac,
+._18qg, ._1_ac, ._529p,
._5aga, ._5ag9, ._537a, .acy, ._5ro_, ._6-l ._2us7, ._4mp, ._2b08,
._14v5 ._14v8, ._1440, ._1442, ._1448, ._4ks_,
textarea, ._24pi, ._4en9, ._1kb, ._5p7j, ._2klz, ._5780, ._5781, ._5782,
diff --git a/app/src/main/assets/css/core/core.css b/app/src/main/assets/css/core/core.css
index 05c6afe1..04560385 100644
--- a/app/src/main/assets/css/core/core.css
+++ b/app/src/main/assets/css/core/core.css
@@ -1,4 +1,4 @@
-[style*="color"], body, input, ._42rv, ._4qau, ._dwm .descArea, ._eu5, ._1tcc, ._3g9-, ._29z_, ._3xz7, ._ib-, ._3bg5 ._56dq, ._477i, ._2vxk, .touched *, ._1_yj, ._1_yl, ._4pj9, ._z-z, ._z-v, ._1e8d, ._36nl, ._36nm, ._2_11, ._2_rf, ._2ip_, ._403p, ._5xu2, ._3ml8, ._3mla, ._50vk, ._1m2u, ._31y7, ._4kcb, ._1lf6, ._1lf5, ._1lf4, ._1hiz, ._xod, ._5ag5, ._43mh, .touch .btn, p, span, .fcg, button, ._52j9, ._52jb, ._52ja, ._5j35, ._rnk, ._24u0, ._1g06, ._14ye, .fcb, ._56cz._56c_, ._1gk_, ._55fj, ._45fu, ._18qg, ._1_ac, ._5aga, ._5ag9, ._537a, .acy, ._5ro_, ._6-l ._2us7, ._4mp, ._2b08, ._14v5 ._14v8, ._1440, ._1442, ._1448, ._4ks_, textarea, ._24pi, ._4en9, ._1kb, ._5p7j, ._2klz, ._5780, ._5781, ._5782, ._3u9u, ._3u9_, ._3u9s, ._1hcx, ._2066, ._1_-1, ._cv_, ._1nbx, ._2cuh, ._4ms9, ._4ms5, ._4ms6, ._31b4, ._31b5, ._5q_r, ._idb, ._27vp, ._4nwe, ._4nw9, ._27vi, .appCenterAppInfo, .appCenterPermissions, ._3c9l, ._3c9m, ._4jn_, ._32qt, ._3mom, ._3moo, ._-7o, ._d00, ._d01, ._559g, ._2new, .appCenterCategorySelectorButton, div.sharerSelector, .footer, .mentions-input, .mentions-placeholder, .largeStatusBox .placeHolder, .fcw, ._5-7t, .fcl, ._4qas, .thread-title, .title, ._46pa, ._336p, ._1rrd, ._2om4, ._3m1m, ._2om2, ._5n_e, .appListExplanation, ._5yt8, ._8he, ._2luw, ._5rgs, h1, h2, h3, h4, h5, h6 { color: #d7b0d7 !important; }
+[style*="color"], body, input, ._42rv, ._4qau, ._dwm .descArea, ._eu5, ._1tcc, ._3g9-, ._29z_, ._3xz7, ._ib-, ._3bg5 ._56dq, ._477i, ._2vxk, .touched *, ._1_yj, ._1_yl, ._4pj9, ._z-z, ._z-v, ._1e8d, ._36nl, ._36nm, ._2_11, ._2_rf, ._2ip_, ._403p, ._5xu2, ._3ml8, ._3mla, ._50vk, ._1m2u, ._31y7, ._4kcb, ._1lf6, ._1lf5, ._1lf4, ._1hiz, ._xod, ._5ag5, ._43mh, .touch .btn, p, span, .fcg, button, ._52j9, ._52jb, ._52ja, ._5j35, ._rnk, ._24u0, ._1g06, ._14ye, .fcb, ._56cz._56c_, ._1gk_, ._55fj, ._45fu, ._18qg, ._1_ac, ._529p, ._5aga, ._5ag9, ._537a, .acy, ._5ro_, ._6-l ._2us7, ._4mp, ._2b08, ._14v5 ._14v8, ._1440, ._1442, ._1448, ._4ks_, textarea, ._24pi, ._4en9, ._1kb, ._5p7j, ._2klz, ._5780, ._5781, ._5782, ._3u9u, ._3u9_, ._3u9s, ._1hcx, ._2066, ._1_-1, ._cv_, ._1nbx, ._2cuh, ._4ms9, ._4ms5, ._4ms6, ._31b4, ._31b5, ._5q_r, ._idb, ._27vp, ._4nwe, ._4nw9, ._27vi, .appCenterAppInfo, .appCenterPermissions, ._3c9l, ._3c9m, ._4jn_, ._32qt, ._3mom, ._3moo, ._-7o, ._d00, ._d01, ._559g, ._2new, .appCenterCategorySelectorButton, div.sharerSelector, .footer, .mentions-input, .mentions-placeholder, .largeStatusBox .placeHolder, .fcw, ._5-7t, .fcl, ._4qas, .thread-title, .title, ._46pa, ._336p, ._1rrd, ._2om4, ._3m1m, ._2om2, ._5n_e, .appListExplanation, ._5yt8, ._8he, ._2luw, ._5rgs, h1, h2, h3, h4, h5, h6 { color: #d7b0d7 !important; }
strong > a, ._15ks ._2q8z._2q8z, ._1e3e { color: #3b5998 !important; }
@@ -8,7 +8,7 @@ a, ._5fpq { color: #d59ed5 !important; }
#viewport { background: #451515 !important; }
-body, #root, #header, [style*="background-color"], ._1upc, input, ._2f9r, ._59e9, ._5pz4, ._5lp4, ._5lp5, .container, .subpage, ._5n_f, #static_templates, ._22_8, ._1t4h, ._uoq, ._6-l ._2us7, ._6-l ._6-p, ._333v, div.sharerSelector, ._1g05, .acy, ._51-g, ._533c, ._ib-, .sharerAttachmentEmpty, .sharerBottomWrapper, ._3bg5 ._56do, ._5hfh, ._52e-, .mQuestionsPollResultsBar, ._5hoc, ._5oxw, ._32_4, ._1hiz, .tlBody, #timelineBody, .timelineX, .timeline, .feed, .tlPrelude, .tlFeedPlaceholder, ._4_d0, .al, ._1gkq, ._5c5b, ._1qxg, ._5luf, ._2new, ._cld, ._3zvb, ._2nk0, .btnD, .btnI, ._11ub, ._5p7j, ._55wm, ._5rgs, ._5xuj, ._1sv1, ._45fu, ._18qg, ._1_ac, ._5w3g, ._3e18, ._5q_r, ._5yt8, ._idb, ._2ip_, ._f6s, ._2l5v, ._8i2, ._kr5, ._2q7u, ._2q7v, ._577z, ._2u4w, ._3u9p, ._3u9t, ._cw4, ._5_y-, ._5_y_, ._5_z3, ._cwy, ._5_z0, ._5_z1, ._5_z2, ._2mtc, ._206a, ._1_-1, ._1ybg, .appCenterCategorySelectorButton, ._5c9u, div._5y57::before, ._59f6._55so::before, .structuredPublisher, ._94v, ._vqv, ._5lp5, ._55wm, ._2om3, ._2ol-, ._1f9d, ._vee, ._31a-, ._3r8b, ._3r9d, ._5vq5, .acw, ._4_xl, ._1p70, ._1p70, ._1ih_, ._51v6, ._u2c, ._484w, ._3ils, ._rm7, ._32qk, ._d01, ._2y60, ._5fu3, ._2foa, ._2y5_, ._38o9, ._1kb, .mAppCenterFatLabel, ._3f50, .mentions-placeholder, .mentions, .mentions-shadow, .mentions-measurer, .acg, ._59tu, ._52z5, ._4l9b, ._4gj3, .groupChromeView, ._i3g, ._3jcf, .error, ._uww, textarea, ._15n_, ._skt, ._5f28, ._14_j, ._3bg5, ._53_-, ._52x1, ._35au, ._cwy, ._1rfn ._1rfk ._4vc-, ._1rfk, ._1rfk ._2v9s, ._301x { background: rgba(255, 0, 255, 0.02) !important; }
+body, #root, #header, [style*="background-color"], ._1upc, input, ._2f9r, ._59e9, ._5pz4, ._5lp4, ._5lp5, .container, .subpage, ._5n_f, #static_templates, ._22_8, ._1t4h, ._uoq, ._6-l ._2us7, ._6-l ._6-p, ._333v, div.sharerSelector, ._529j, ._1g05, .acy, ._51-g, ._533c, ._ib-, .sharerAttachmentEmpty, .sharerBottomWrapper, ._3bg5 ._56do, ._5hfh, ._52e-, .mQuestionsPollResultsBar, ._5hoc, ._5oxw, ._32_4, ._1hiz, .tlBody, #timelineBody, .timelineX, .timeline, .feed, .tlPrelude, .tlFeedPlaceholder, ._4_d0, .al, ._1gkq, ._5c5b, ._1qxg, ._5luf, ._2new, ._cld, ._3zvb, ._2nk0, .btnD, .btnI, ._11ub, ._5p7j, ._55wm, ._5rgs, ._5xuj, ._1sv1, ._45fu, ._18qg, ._1_ac, ._5w3g, ._3e18, ._5q_r, ._5yt8, ._idb, ._2ip_, ._f6s, ._2l5v, ._8i2, ._kr5, ._2q7u, ._2q7v, ._577z, ._2u4w, ._3u9p, ._3u9t, ._cw4, ._5_y-, ._5_y_, ._5_z3, ._cwy, ._5_z0, ._5_z1, ._5_z2, ._2mtc, ._206a, ._1_-1, ._1ybg, .appCenterCategorySelectorButton, ._5c9u, div._5y57::before, ._59f6._55so::before, .structuredPublisher, ._94v, ._vqv, ._5lp5, ._55wm, ._2om3, ._2ol-, ._1f9d, ._vee, ._31a-, ._3r8b, ._3r9d, ._5vq5, .acw, ._4_xl, ._1p70, ._1p70, ._1ih_, ._51v6, ._u2c, ._484w, ._3ils, ._rm7, ._32qk, ._d01, ._2y60, ._5fu3, ._2foa, ._2y5_, ._38o9, ._1kb, .mAppCenterFatLabel, ._3f50, .mentions-placeholder, .mentions, .mentions-shadow, .mentions-measurer, .acg, ._59tu, ._52z5, ._4l9b, ._4gj3, .groupChromeView, ._i3g, ._3jcf, .error, ._uww, textarea, ._15n_, ._skt, ._5f28, ._14_j, ._3bg5, ._53_-, ._52x1, ._35au, ._cwy, ._1rfn ._1rfk ._4vc-, ._1rfk, ._1rfk ._2v9s, ._301x { background: rgba(255, 0, 255, 0.02) !important; }
._31nf, ._2v9s, ._d4i, article._55wo, ._10c_, ._2jl2, ._55wo { background: #496296 !important; }
@@ -28,6 +28,8 @@ button:not([style*=image]), button::before, .touch ._56bt, ._56be::before, .btnS
._220g, ._1_y8:after, ._2zh4::before, ._2ip_ ._2zh4::before, ._2ip_ ._15kk::before, ._2ip_ ._15kk + ._4u3j::before, ._58a0:before, ._43mh::before, ._43mh::after, ._1_-1::before, ._1kmv:after, ._1_ac:before { background: rgba(215, 176, 215, 0.3) !important; }
+button ._v89 ._54k8._1fl1 { background: #3b5998 !important; }
+
._15kl::before, ._37fd .inlineComposerButton, ._1hb:before, ._5j35::after, ._2k4b, ._3to7, ._4nw8 { border-left: 1px solid rgba(215, 176, 215, 0.3) !important; }
._4_d1 { border-right: 1px solid rgba(215, 176, 215, 0.3) !important; }
diff --git a/app/src/main/assets/css/themes/custom.css b/app/src/main/assets/css/themes/custom.css
index 340e9139..c4fd8c7c 100644
--- a/app/src/main/assets/css/themes/custom.css
+++ b/app/src/main/assets/css/themes/custom.css
@@ -1,4 +1,4 @@
-[style*="color"], body, input, ._42rv, ._4qau, ._dwm .descArea, ._eu5, ._1tcc, ._3g9-, ._29z_, ._3xz7, ._ib-, ._3bg5 ._56dq, ._477i, ._2vxk, .touched *, ._1_yj, ._1_yl, ._4pj9, ._z-z, ._z-v, ._1e8d, ._36nl, ._36nm, ._2_11, ._2_rf, ._2ip_, ._403p, ._5xu2, ._3ml8, ._3mla, ._50vk, ._1m2u, ._31y7, ._4kcb, ._1lf6, ._1lf5, ._1lf4, ._1hiz, ._xod, ._5ag5, ._43mh, .touch .btn, p, span, .fcg, button, ._52j9, ._52jb, ._52ja, ._5j35, ._rnk, ._24u0, ._1g06, ._14ye, .fcb, ._56cz._56c_, ._1gk_, ._55fj, ._45fu, ._18qg, ._1_ac, ._5aga, ._5ag9, ._537a, .acy, ._5ro_, ._6-l ._2us7, ._4mp, ._2b08, ._14v5 ._14v8, ._1440, ._1442, ._1448, ._4ks_, textarea, ._24pi, ._4en9, ._1kb, ._5p7j, ._2klz, ._5780, ._5781, ._5782, ._3u9u, ._3u9_, ._3u9s, ._1hcx, ._2066, ._1_-1, ._cv_, ._1nbx, ._2cuh, ._4ms9, ._4ms5, ._4ms6, ._31b4, ._31b5, ._5q_r, ._idb, ._27vp, ._4nwe, ._4nw9, ._27vi, .appCenterAppInfo, .appCenterPermissions, ._3c9l, ._3c9m, ._4jn_, ._32qt, ._3mom, ._3moo, ._-7o, ._d00, ._d01, ._559g, ._2new, .appCenterCategorySelectorButton, div.sharerSelector, .footer, .mentions-input, .mentions-placeholder, .largeStatusBox .placeHolder, .fcw, ._5-7t, .fcl, ._4qas, .thread-title, .title, ._46pa, ._336p, ._1rrd, ._2om4, ._3m1m, ._2om2, ._5n_e, .appListExplanation, ._5yt8, ._8he, ._2luw, ._5rgs, h1, h2, h3, h4, h5, h6 { color: $T$ !important; }
+[style*="color"], body, input, ._42rv, ._4qau, ._dwm .descArea, ._eu5, ._1tcc, ._3g9-, ._29z_, ._3xz7, ._ib-, ._3bg5 ._56dq, ._477i, ._2vxk, .touched *, ._1_yj, ._1_yl, ._4pj9, ._z-z, ._z-v, ._1e8d, ._36nl, ._36nm, ._2_11, ._2_rf, ._2ip_, ._403p, ._5xu2, ._3ml8, ._3mla, ._50vk, ._1m2u, ._31y7, ._4kcb, ._1lf6, ._1lf5, ._1lf4, ._1hiz, ._xod, ._5ag5, ._43mh, .touch .btn, p, span, .fcg, button, ._52j9, ._52jb, ._52ja, ._5j35, ._rnk, ._24u0, ._1g06, ._14ye, .fcb, ._56cz._56c_, ._1gk_, ._55fj, ._45fu, ._18qg, ._1_ac, ._529p, ._5aga, ._5ag9, ._537a, .acy, ._5ro_, ._6-l ._2us7, ._4mp, ._2b08, ._14v5 ._14v8, ._1440, ._1442, ._1448, ._4ks_, textarea, ._24pi, ._4en9, ._1kb, ._5p7j, ._2klz, ._5780, ._5781, ._5782, ._3u9u, ._3u9_, ._3u9s, ._1hcx, ._2066, ._1_-1, ._cv_, ._1nbx, ._2cuh, ._4ms9, ._4ms5, ._4ms6, ._31b4, ._31b5, ._5q_r, ._idb, ._27vp, ._4nwe, ._4nw9, ._27vi, .appCenterAppInfo, .appCenterPermissions, ._3c9l, ._3c9m, ._4jn_, ._32qt, ._3mom, ._3moo, ._-7o, ._d00, ._d01, ._559g, ._2new, .appCenterCategorySelectorButton, div.sharerSelector, .footer, .mentions-input, .mentions-placeholder, .largeStatusBox .placeHolder, .fcw, ._5-7t, .fcl, ._4qas, .thread-title, .title, ._46pa, ._336p, ._1rrd, ._2om4, ._3m1m, ._2om2, ._5n_e, .appListExplanation, ._5yt8, ._8he, ._2luw, ._5rgs, h1, h2, h3, h4, h5, h6 { color: $T$ !important; }
strong > a, ._15ks ._2q8z._2q8z, ._1e3e { color: $A$ !important; }
@@ -8,7 +8,7 @@ a, ._5fpq { color: $TT$ !important; }
#viewport { background: $B$ !important; }
-body, #root, #header, [style*="background-color"], ._1upc, input, ._2f9r, ._59e9, ._5pz4, ._5lp4, ._5lp5, .container, .subpage, ._5n_f, #static_templates, ._22_8, ._1t4h, ._uoq, ._6-l ._2us7, ._6-l ._6-p, ._333v, div.sharerSelector, ._1g05, .acy, ._51-g, ._533c, ._ib-, .sharerAttachmentEmpty, .sharerBottomWrapper, ._3bg5 ._56do, ._5hfh, ._52e-, .mQuestionsPollResultsBar, ._5hoc, ._5oxw, ._32_4, ._1hiz, .tlBody, #timelineBody, .timelineX, .timeline, .feed, .tlPrelude, .tlFeedPlaceholder, ._4_d0, .al, ._1gkq, ._5c5b, ._1qxg, ._5luf, ._2new, ._cld, ._3zvb, ._2nk0, .btnD, .btnI, ._11ub, ._5p7j, ._55wm, ._5rgs, ._5xuj, ._1sv1, ._45fu, ._18qg, ._1_ac, ._5w3g, ._3e18, ._5q_r, ._5yt8, ._idb, ._2ip_, ._f6s, ._2l5v, ._8i2, ._kr5, ._2q7u, ._2q7v, ._577z, ._2u4w, ._3u9p, ._3u9t, ._cw4, ._5_y-, ._5_y_, ._5_z3, ._cwy, ._5_z0, ._5_z1, ._5_z2, ._2mtc, ._206a, ._1_-1, ._1ybg, .appCenterCategorySelectorButton, ._5c9u, div._5y57::before, ._59f6._55so::before, .structuredPublisher, ._94v, ._vqv, ._5lp5, ._55wm, ._2om3, ._2ol-, ._1f9d, ._vee, ._31a-, ._3r8b, ._3r9d, ._5vq5, .acw, ._4_xl, ._1p70, ._1p70, ._1ih_, ._51v6, ._u2c, ._484w, ._3ils, ._rm7, ._32qk, ._d01, ._2y60, ._5fu3, ._2foa, ._2y5_, ._38o9, ._1kb, .mAppCenterFatLabel, ._3f50, .mentions-placeholder, .mentions, .mentions-shadow, .mentions-measurer, .acg, ._59tu, ._52z5, ._4l9b, ._4gj3, .groupChromeView, ._i3g, ._3jcf, .error, ._uww, textarea, ._15n_, ._skt, ._5f28, ._14_j, ._3bg5, ._53_-, ._52x1, ._35au, ._cwy, ._1rfn ._1rfk ._4vc-, ._1rfk, ._1rfk ._2v9s, ._301x { background: $BT$ !important; }
+body, #root, #header, [style*="background-color"], ._1upc, input, ._2f9r, ._59e9, ._5pz4, ._5lp4, ._5lp5, .container, .subpage, ._5n_f, #static_templates, ._22_8, ._1t4h, ._uoq, ._6-l ._2us7, ._6-l ._6-p, ._333v, div.sharerSelector, ._529j, ._1g05, .acy, ._51-g, ._533c, ._ib-, .sharerAttachmentEmpty, .sharerBottomWrapper, ._3bg5 ._56do, ._5hfh, ._52e-, .mQuestionsPollResultsBar, ._5hoc, ._5oxw, ._32_4, ._1hiz, .tlBody, #timelineBody, .timelineX, .timeline, .feed, .tlPrelude, .tlFeedPlaceholder, ._4_d0, .al, ._1gkq, ._5c5b, ._1qxg, ._5luf, ._2new, ._cld, ._3zvb, ._2nk0, .btnD, .btnI, ._11ub, ._5p7j, ._55wm, ._5rgs, ._5xuj, ._1sv1, ._45fu, ._18qg, ._1_ac, ._5w3g, ._3e18, ._5q_r, ._5yt8, ._idb, ._2ip_, ._f6s, ._2l5v, ._8i2, ._kr5, ._2q7u, ._2q7v, ._577z, ._2u4w, ._3u9p, ._3u9t, ._cw4, ._5_y-, ._5_y_, ._5_z3, ._cwy, ._5_z0, ._5_z1, ._5_z2, ._2mtc, ._206a, ._1_-1, ._1ybg, .appCenterCategorySelectorButton, ._5c9u, div._5y57::before, ._59f6._55so::before, .structuredPublisher, ._94v, ._vqv, ._5lp5, ._55wm, ._2om3, ._2ol-, ._1f9d, ._vee, ._31a-, ._3r8b, ._3r9d, ._5vq5, .acw, ._4_xl, ._1p70, ._1p70, ._1ih_, ._51v6, ._u2c, ._484w, ._3ils, ._rm7, ._32qk, ._d01, ._2y60, ._5fu3, ._2foa, ._2y5_, ._38o9, ._1kb, .mAppCenterFatLabel, ._3f50, .mentions-placeholder, .mentions, .mentions-shadow, .mentions-measurer, .acg, ._59tu, ._52z5, ._4l9b, ._4gj3, .groupChromeView, ._i3g, ._3jcf, .error, ._uww, textarea, ._15n_, ._skt, ._5f28, ._14_j, ._3bg5, ._53_-, ._52x1, ._35au, ._cwy, ._1rfn ._1rfk ._4vc-, ._1rfk, ._1rfk ._2v9s, ._301x { background: $BT$ !important; }
._31nf, ._2v9s, ._d4i, article._55wo, ._10c_, ._2jl2, ._55wo { background: $C$ !important; }
@@ -28,6 +28,8 @@ button:not([style*=image]), button::before, .touch ._56bt, ._56be::before, .btnS
._220g, ._1_y8:after, ._2zh4::before, ._2ip_ ._2zh4::before, ._2ip_ ._15kk::before, ._2ip_ ._15kk + ._4u3j::before, ._58a0:before, ._43mh::before, ._43mh::after, ._1_-1::before, ._1kmv:after, ._1_ac:before { background: $D$ !important; }
+button ._v89 ._54k8._1fl1 { background: $A$ !important; }
+
._15kl::before, ._37fd .inlineComposerButton, ._1hb:before, ._5j35::after, ._2k4b, ._3to7, ._4nw8 { border-left: 1px solid $D$ !important; }
._4_d1 { border-right: 1px solid $D$ !important; }
diff --git a/app/src/main/assets/css/themes/material_amoled.css b/app/src/main/assets/css/themes/material_amoled.css
index fd0d4026..8cfae227 100644
--- a/app/src/main/assets/css/themes/material_amoled.css
+++ b/app/src/main/assets/css/themes/material_amoled.css
@@ -1,4 +1,4 @@
-[style*="color"], body, input, ._42rv, ._4qau, ._dwm .descArea, ._eu5, ._1tcc, ._3g9-, ._29z_, ._3xz7, ._ib-, ._3bg5 ._56dq, ._477i, ._2vxk, .touched *, ._1_yj, ._1_yl, ._4pj9, ._z-z, ._z-v, ._1e8d, ._36nl, ._36nm, ._2_11, ._2_rf, ._2ip_, ._403p, ._5xu2, ._3ml8, ._3mla, ._50vk, ._1m2u, ._31y7, ._4kcb, ._1lf6, ._1lf5, ._1lf4, ._1hiz, ._xod, ._5ag5, ._43mh, .touch .btn, p, span, .fcg, button, ._52j9, ._52jb, ._52ja, ._5j35, ._rnk, ._24u0, ._1g06, ._14ye, .fcb, ._56cz._56c_, ._1gk_, ._55fj, ._45fu, ._18qg, ._1_ac, ._5aga, ._5ag9, ._537a, .acy, ._5ro_, ._6-l ._2us7, ._4mp, ._2b08, ._14v5 ._14v8, ._1440, ._1442, ._1448, ._4ks_, textarea, ._24pi, ._4en9, ._1kb, ._5p7j, ._2klz, ._5780, ._5781, ._5782, ._3u9u, ._3u9_, ._3u9s, ._1hcx, ._2066, ._1_-1, ._cv_, ._1nbx, ._2cuh, ._4ms9, ._4ms5, ._4ms6, ._31b4, ._31b5, ._5q_r, ._idb, ._27vp, ._4nwe, ._4nw9, ._27vi, .appCenterAppInfo, .appCenterPermissions, ._3c9l, ._3c9m, ._4jn_, ._32qt, ._3mom, ._3moo, ._-7o, ._d00, ._d01, ._559g, ._2new, .appCenterCategorySelectorButton, div.sharerSelector, .footer, .mentions-input, .mentions-placeholder, .largeStatusBox .placeHolder, .fcw, ._5-7t, .fcl, ._4qas, .thread-title, .title, ._46pa, ._336p, ._1rrd, ._2om4, ._3m1m, ._2om2, ._5n_e, .appListExplanation, ._5yt8, ._8he, ._2luw, ._5rgs, h1, h2, h3, h4, h5, h6 { color: #fff !important; }
+[style*="color"], body, input, ._42rv, ._4qau, ._dwm .descArea, ._eu5, ._1tcc, ._3g9-, ._29z_, ._3xz7, ._ib-, ._3bg5 ._56dq, ._477i, ._2vxk, .touched *, ._1_yj, ._1_yl, ._4pj9, ._z-z, ._z-v, ._1e8d, ._36nl, ._36nm, ._2_11, ._2_rf, ._2ip_, ._403p, ._5xu2, ._3ml8, ._3mla, ._50vk, ._1m2u, ._31y7, ._4kcb, ._1lf6, ._1lf5, ._1lf4, ._1hiz, ._xod, ._5ag5, ._43mh, .touch .btn, p, span, .fcg, button, ._52j9, ._52jb, ._52ja, ._5j35, ._rnk, ._24u0, ._1g06, ._14ye, .fcb, ._56cz._56c_, ._1gk_, ._55fj, ._45fu, ._18qg, ._1_ac, ._529p, ._5aga, ._5ag9, ._537a, .acy, ._5ro_, ._6-l ._2us7, ._4mp, ._2b08, ._14v5 ._14v8, ._1440, ._1442, ._1448, ._4ks_, textarea, ._24pi, ._4en9, ._1kb, ._5p7j, ._2klz, ._5780, ._5781, ._5782, ._3u9u, ._3u9_, ._3u9s, ._1hcx, ._2066, ._1_-1, ._cv_, ._1nbx, ._2cuh, ._4ms9, ._4ms5, ._4ms6, ._31b4, ._31b5, ._5q_r, ._idb, ._27vp, ._4nwe, ._4nw9, ._27vi, .appCenterAppInfo, .appCenterPermissions, ._3c9l, ._3c9m, ._4jn_, ._32qt, ._3mom, ._3moo, ._-7o, ._d00, ._d01, ._559g, ._2new, .appCenterCategorySelectorButton, div.sharerSelector, .footer, .mentions-input, .mentions-placeholder, .largeStatusBox .placeHolder, .fcw, ._5-7t, .fcl, ._4qas, .thread-title, .title, ._46pa, ._336p, ._1rrd, ._2om4, ._3m1m, ._2om2, ._5n_e, .appListExplanation, ._5yt8, ._8he, ._2luw, ._5rgs, h1, h2, h3, h4, h5, h6 { color: #fff !important; }
strong > a, ._15ks ._2q8z._2q8z, ._1e3e { color: #5d86dd !important; }
@@ -8,7 +8,7 @@ a, ._5fpq { color: #eee !important; }
#viewport { background: #000 !important; }
-body, #root, #header, [style*="background-color"], ._1upc, input, ._2f9r, ._59e9, ._5pz4, ._5lp4, ._5lp5, .container, .subpage, ._5n_f, #static_templates, ._22_8, ._1t4h, ._uoq, ._6-l ._2us7, ._6-l ._6-p, ._333v, div.sharerSelector, ._1g05, .acy, ._51-g, ._533c, ._ib-, .sharerAttachmentEmpty, .sharerBottomWrapper, ._3bg5 ._56do, ._5hfh, ._52e-, .mQuestionsPollResultsBar, ._5hoc, ._5oxw, ._32_4, ._1hiz, .tlBody, #timelineBody, .timelineX, .timeline, .feed, .tlPrelude, .tlFeedPlaceholder, ._4_d0, .al, ._1gkq, ._5c5b, ._1qxg, ._5luf, ._2new, ._cld, ._3zvb, ._2nk0, .btnD, .btnI, ._11ub, ._5p7j, ._55wm, ._5rgs, ._5xuj, ._1sv1, ._45fu, ._18qg, ._1_ac, ._5w3g, ._3e18, ._5q_r, ._5yt8, ._idb, ._2ip_, ._f6s, ._2l5v, ._8i2, ._kr5, ._2q7u, ._2q7v, ._577z, ._2u4w, ._3u9p, ._3u9t, ._cw4, ._5_y-, ._5_y_, ._5_z3, ._cwy, ._5_z0, ._5_z1, ._5_z2, ._2mtc, ._206a, ._1_-1, ._1ybg, .appCenterCategorySelectorButton, ._5c9u, div._5y57::before, ._59f6._55so::before, .structuredPublisher, ._94v, ._vqv, ._5lp5, ._55wm, ._2om3, ._2ol-, ._1f9d, ._vee, ._31a-, ._3r8b, ._3r9d, ._5vq5, .acw, ._4_xl, ._1p70, ._1p70, ._1ih_, ._51v6, ._u2c, ._484w, ._3ils, ._rm7, ._32qk, ._d01, ._2y60, ._5fu3, ._2foa, ._2y5_, ._38o9, ._1kb, .mAppCenterFatLabel, ._3f50, .mentions-placeholder, .mentions, .mentions-shadow, .mentions-measurer, .acg, ._59tu, ._52z5, ._4l9b, ._4gj3, .groupChromeView, ._i3g, ._3jcf, .error, ._uww, textarea, ._15n_, ._skt, ._5f28, ._14_j, ._3bg5, ._53_-, ._52x1, ._35au, ._cwy, ._1rfn ._1rfk ._4vc-, ._1rfk, ._1rfk ._2v9s, ._301x { background: #000 !important; }
+body, #root, #header, [style*="background-color"], ._1upc, input, ._2f9r, ._59e9, ._5pz4, ._5lp4, ._5lp5, .container, .subpage, ._5n_f, #static_templates, ._22_8, ._1t4h, ._uoq, ._6-l ._2us7, ._6-l ._6-p, ._333v, div.sharerSelector, ._529j, ._1g05, .acy, ._51-g, ._533c, ._ib-, .sharerAttachmentEmpty, .sharerBottomWrapper, ._3bg5 ._56do, ._5hfh, ._52e-, .mQuestionsPollResultsBar, ._5hoc, ._5oxw, ._32_4, ._1hiz, .tlBody, #timelineBody, .timelineX, .timeline, .feed, .tlPrelude, .tlFeedPlaceholder, ._4_d0, .al, ._1gkq, ._5c5b, ._1qxg, ._5luf, ._2new, ._cld, ._3zvb, ._2nk0, .btnD, .btnI, ._11ub, ._5p7j, ._55wm, ._5rgs, ._5xuj, ._1sv1, ._45fu, ._18qg, ._1_ac, ._5w3g, ._3e18, ._5q_r, ._5yt8, ._idb, ._2ip_, ._f6s, ._2l5v, ._8i2, ._kr5, ._2q7u, ._2q7v, ._577z, ._2u4w, ._3u9p, ._3u9t, ._cw4, ._5_y-, ._5_y_, ._5_z3, ._cwy, ._5_z0, ._5_z1, ._5_z2, ._2mtc, ._206a, ._1_-1, ._1ybg, .appCenterCategorySelectorButton, ._5c9u, div._5y57::before, ._59f6._55so::before, .structuredPublisher, ._94v, ._vqv, ._5lp5, ._55wm, ._2om3, ._2ol-, ._1f9d, ._vee, ._31a-, ._3r8b, ._3r9d, ._5vq5, .acw, ._4_xl, ._1p70, ._1p70, ._1ih_, ._51v6, ._u2c, ._484w, ._3ils, ._rm7, ._32qk, ._d01, ._2y60, ._5fu3, ._2foa, ._2y5_, ._38o9, ._1kb, .mAppCenterFatLabel, ._3f50, .mentions-placeholder, .mentions, .mentions-shadow, .mentions-measurer, .acg, ._59tu, ._52z5, ._4l9b, ._4gj3, .groupChromeView, ._i3g, ._3jcf, .error, ._uww, textarea, ._15n_, ._skt, ._5f28, ._14_j, ._3bg5, ._53_-, ._52x1, ._35au, ._cwy, ._1rfn ._1rfk ._4vc-, ._1rfk, ._1rfk ._2v9s, ._301x { background: #000 !important; }
._31nf, ._2v9s, ._d4i, article._55wo, ._10c_, ._2jl2, ._55wo { background: rgba(0, 0, 0, 0.35) !important; }
@@ -28,6 +28,8 @@ button:not([style*=image]), button::before, .touch ._56bt, ._56be::before, .btnS
._220g, ._1_y8:after, ._2zh4::before, ._2ip_ ._2zh4::before, ._2ip_ ._15kk::before, ._2ip_ ._15kk + ._4u3j::before, ._58a0:before, ._43mh::before, ._43mh::after, ._1_-1::before, ._1kmv:after, ._1_ac:before { background: rgba(255, 255, 255, 0.3) !important; }
+button ._v89 ._54k8._1fl1 { background: #5d86dd !important; }
+
._15kl::before, ._37fd .inlineComposerButton, ._1hb:before, ._5j35::after, ._2k4b, ._3to7, ._4nw8 { border-left: 1px solid rgba(255, 255, 255, 0.3) !important; }
._4_d1 { border-right: 1px solid rgba(255, 255, 255, 0.3) !important; }
diff --git a/app/src/main/assets/css/themes/material_dark.css b/app/src/main/assets/css/themes/material_dark.css
index ce5e00bf..fc57ac30 100644
--- a/app/src/main/assets/css/themes/material_dark.css
+++ b/app/src/main/assets/css/themes/material_dark.css
@@ -1,4 +1,4 @@
-[style*="color"], body, input, ._42rv, ._4qau, ._dwm .descArea, ._eu5, ._1tcc, ._3g9-, ._29z_, ._3xz7, ._ib-, ._3bg5 ._56dq, ._477i, ._2vxk, .touched *, ._1_yj, ._1_yl, ._4pj9, ._z-z, ._z-v, ._1e8d, ._36nl, ._36nm, ._2_11, ._2_rf, ._2ip_, ._403p, ._5xu2, ._3ml8, ._3mla, ._50vk, ._1m2u, ._31y7, ._4kcb, ._1lf6, ._1lf5, ._1lf4, ._1hiz, ._xod, ._5ag5, ._43mh, .touch .btn, p, span, .fcg, button, ._52j9, ._52jb, ._52ja, ._5j35, ._rnk, ._24u0, ._1g06, ._14ye, .fcb, ._56cz._56c_, ._1gk_, ._55fj, ._45fu, ._18qg, ._1_ac, ._5aga, ._5ag9, ._537a, .acy, ._5ro_, ._6-l ._2us7, ._4mp, ._2b08, ._14v5 ._14v8, ._1440, ._1442, ._1448, ._4ks_, textarea, ._24pi, ._4en9, ._1kb, ._5p7j, ._2klz, ._5780, ._5781, ._5782, ._3u9u, ._3u9_, ._3u9s, ._1hcx, ._2066, ._1_-1, ._cv_, ._1nbx, ._2cuh, ._4ms9, ._4ms5, ._4ms6, ._31b4, ._31b5, ._5q_r, ._idb, ._27vp, ._4nwe, ._4nw9, ._27vi, .appCenterAppInfo, .appCenterPermissions, ._3c9l, ._3c9m, ._4jn_, ._32qt, ._3mom, ._3moo, ._-7o, ._d00, ._d01, ._559g, ._2new, .appCenterCategorySelectorButton, div.sharerSelector, .footer, .mentions-input, .mentions-placeholder, .largeStatusBox .placeHolder, .fcw, ._5-7t, .fcl, ._4qas, .thread-title, .title, ._46pa, ._336p, ._1rrd, ._2om4, ._3m1m, ._2om2, ._5n_e, .appListExplanation, ._5yt8, ._8he, ._2luw, ._5rgs, h1, h2, h3, h4, h5, h6 { color: #fff !important; }
+[style*="color"], body, input, ._42rv, ._4qau, ._dwm .descArea, ._eu5, ._1tcc, ._3g9-, ._29z_, ._3xz7, ._ib-, ._3bg5 ._56dq, ._477i, ._2vxk, .touched *, ._1_yj, ._1_yl, ._4pj9, ._z-z, ._z-v, ._1e8d, ._36nl, ._36nm, ._2_11, ._2_rf, ._2ip_, ._403p, ._5xu2, ._3ml8, ._3mla, ._50vk, ._1m2u, ._31y7, ._4kcb, ._1lf6, ._1lf5, ._1lf4, ._1hiz, ._xod, ._5ag5, ._43mh, .touch .btn, p, span, .fcg, button, ._52j9, ._52jb, ._52ja, ._5j35, ._rnk, ._24u0, ._1g06, ._14ye, .fcb, ._56cz._56c_, ._1gk_, ._55fj, ._45fu, ._18qg, ._1_ac, ._529p, ._5aga, ._5ag9, ._537a, .acy, ._5ro_, ._6-l ._2us7, ._4mp, ._2b08, ._14v5 ._14v8, ._1440, ._1442, ._1448, ._4ks_, textarea, ._24pi, ._4en9, ._1kb, ._5p7j, ._2klz, ._5780, ._5781, ._5782, ._3u9u, ._3u9_, ._3u9s, ._1hcx, ._2066, ._1_-1, ._cv_, ._1nbx, ._2cuh, ._4ms9, ._4ms5, ._4ms6, ._31b4, ._31b5, ._5q_r, ._idb, ._27vp, ._4nwe, ._4nw9, ._27vi, .appCenterAppInfo, .appCenterPermissions, ._3c9l, ._3c9m, ._4jn_, ._32qt, ._3mom, ._3moo, ._-7o, ._d00, ._d01, ._559g, ._2new, .appCenterCategorySelectorButton, div.sharerSelector, .footer, .mentions-input, .mentions-placeholder, .largeStatusBox .placeHolder, .fcw, ._5-7t, .fcl, ._4qas, .thread-title, .title, ._46pa, ._336p, ._1rrd, ._2om4, ._3m1m, ._2om2, ._5n_e, .appListExplanation, ._5yt8, ._8he, ._2luw, ._5rgs, h1, h2, h3, h4, h5, h6 { color: #fff !important; }
strong > a, ._15ks ._2q8z._2q8z, ._1e3e { color: #5d86dd !important; }
@@ -8,7 +8,7 @@ a, ._5fpq { color: #eee !important; }
#viewport { background: #303030 !important; }
-body, #root, #header, [style*="background-color"], ._1upc, input, ._2f9r, ._59e9, ._5pz4, ._5lp4, ._5lp5, .container, .subpage, ._5n_f, #static_templates, ._22_8, ._1t4h, ._uoq, ._6-l ._2us7, ._6-l ._6-p, ._333v, div.sharerSelector, ._1g05, .acy, ._51-g, ._533c, ._ib-, .sharerAttachmentEmpty, .sharerBottomWrapper, ._3bg5 ._56do, ._5hfh, ._52e-, .mQuestionsPollResultsBar, ._5hoc, ._5oxw, ._32_4, ._1hiz, .tlBody, #timelineBody, .timelineX, .timeline, .feed, .tlPrelude, .tlFeedPlaceholder, ._4_d0, .al, ._1gkq, ._5c5b, ._1qxg, ._5luf, ._2new, ._cld, ._3zvb, ._2nk0, .btnD, .btnI, ._11ub, ._5p7j, ._55wm, ._5rgs, ._5xuj, ._1sv1, ._45fu, ._18qg, ._1_ac, ._5w3g, ._3e18, ._5q_r, ._5yt8, ._idb, ._2ip_, ._f6s, ._2l5v, ._8i2, ._kr5, ._2q7u, ._2q7v, ._577z, ._2u4w, ._3u9p, ._3u9t, ._cw4, ._5_y-, ._5_y_, ._5_z3, ._cwy, ._5_z0, ._5_z1, ._5_z2, ._2mtc, ._206a, ._1_-1, ._1ybg, .appCenterCategorySelectorButton, ._5c9u, div._5y57::before, ._59f6._55so::before, .structuredPublisher, ._94v, ._vqv, ._5lp5, ._55wm, ._2om3, ._2ol-, ._1f9d, ._vee, ._31a-, ._3r8b, ._3r9d, ._5vq5, .acw, ._4_xl, ._1p70, ._1p70, ._1ih_, ._51v6, ._u2c, ._484w, ._3ils, ._rm7, ._32qk, ._d01, ._2y60, ._5fu3, ._2foa, ._2y5_, ._38o9, ._1kb, .mAppCenterFatLabel, ._3f50, .mentions-placeholder, .mentions, .mentions-shadow, .mentions-measurer, .acg, ._59tu, ._52z5, ._4l9b, ._4gj3, .groupChromeView, ._i3g, ._3jcf, .error, ._uww, textarea, ._15n_, ._skt, ._5f28, ._14_j, ._3bg5, ._53_-, ._52x1, ._35au, ._cwy, ._1rfn ._1rfk ._4vc-, ._1rfk, ._1rfk ._2v9s, ._301x { background: #303030 !important; }
+body, #root, #header, [style*="background-color"], ._1upc, input, ._2f9r, ._59e9, ._5pz4, ._5lp4, ._5lp5, .container, .subpage, ._5n_f, #static_templates, ._22_8, ._1t4h, ._uoq, ._6-l ._2us7, ._6-l ._6-p, ._333v, div.sharerSelector, ._529j, ._1g05, .acy, ._51-g, ._533c, ._ib-, .sharerAttachmentEmpty, .sharerBottomWrapper, ._3bg5 ._56do, ._5hfh, ._52e-, .mQuestionsPollResultsBar, ._5hoc, ._5oxw, ._32_4, ._1hiz, .tlBody, #timelineBody, .timelineX, .timeline, .feed, .tlPrelude, .tlFeedPlaceholder, ._4_d0, .al, ._1gkq, ._5c5b, ._1qxg, ._5luf, ._2new, ._cld, ._3zvb, ._2nk0, .btnD, .btnI, ._11ub, ._5p7j, ._55wm, ._5rgs, ._5xuj, ._1sv1, ._45fu, ._18qg, ._1_ac, ._5w3g, ._3e18, ._5q_r, ._5yt8, ._idb, ._2ip_, ._f6s, ._2l5v, ._8i2, ._kr5, ._2q7u, ._2q7v, ._577z, ._2u4w, ._3u9p, ._3u9t, ._cw4, ._5_y-, ._5_y_, ._5_z3, ._cwy, ._5_z0, ._5_z1, ._5_z2, ._2mtc, ._206a, ._1_-1, ._1ybg, .appCenterCategorySelectorButton, ._5c9u, div._5y57::before, ._59f6._55so::before, .structuredPublisher, ._94v, ._vqv, ._5lp5, ._55wm, ._2om3, ._2ol-, ._1f9d, ._vee, ._31a-, ._3r8b, ._3r9d, ._5vq5, .acw, ._4_xl, ._1p70, ._1p70, ._1ih_, ._51v6, ._u2c, ._484w, ._3ils, ._rm7, ._32qk, ._d01, ._2y60, ._5fu3, ._2foa, ._2y5_, ._38o9, ._1kb, .mAppCenterFatLabel, ._3f50, .mentions-placeholder, .mentions, .mentions-shadow, .mentions-measurer, .acg, ._59tu, ._52z5, ._4l9b, ._4gj3, .groupChromeView, ._i3g, ._3jcf, .error, ._uww, textarea, ._15n_, ._skt, ._5f28, ._14_j, ._3bg5, ._53_-, ._52x1, ._35au, ._cwy, ._1rfn ._1rfk ._4vc-, ._1rfk, ._1rfk ._2v9s, ._301x { background: #303030 !important; }
._31nf, ._2v9s, ._d4i, article._55wo, ._10c_, ._2jl2, ._55wo { background: #353535 !important; }
@@ -28,6 +28,8 @@ button:not([style*=image]), button::before, .touch ._56bt, ._56be::before, .btnS
._220g, ._1_y8:after, ._2zh4::before, ._2ip_ ._2zh4::before, ._2ip_ ._15kk::before, ._2ip_ ._15kk + ._4u3j::before, ._58a0:before, ._43mh::before, ._43mh::after, ._1_-1::before, ._1kmv:after, ._1_ac:before { background: rgba(255, 255, 255, 0.3) !important; }
+button ._v89 ._54k8._1fl1 { background: #5d86dd !important; }
+
._15kl::before, ._37fd .inlineComposerButton, ._1hb:before, ._5j35::after, ._2k4b, ._3to7, ._4nw8 { border-left: 1px solid rgba(255, 255, 255, 0.3) !important; }
._4_d1 { border-right: 1px solid rgba(255, 255, 255, 0.3) !important; }
diff --git a/app/src/main/assets/css/themes/material_glass.css b/app/src/main/assets/css/themes/material_glass.css
index 695a2489..7d79dc78 100644
--- a/app/src/main/assets/css/themes/material_glass.css
+++ b/app/src/main/assets/css/themes/material_glass.css
@@ -1,4 +1,4 @@
-[style*="color"], body, input, ._42rv, ._4qau, ._dwm .descArea, ._eu5, ._1tcc, ._3g9-, ._29z_, ._3xz7, ._ib-, ._3bg5 ._56dq, ._477i, ._2vxk, .touched *, ._1_yj, ._1_yl, ._4pj9, ._z-z, ._z-v, ._1e8d, ._36nl, ._36nm, ._2_11, ._2_rf, ._2ip_, ._403p, ._5xu2, ._3ml8, ._3mla, ._50vk, ._1m2u, ._31y7, ._4kcb, ._1lf6, ._1lf5, ._1lf4, ._1hiz, ._xod, ._5ag5, ._43mh, .touch .btn, p, span, .fcg, button, ._52j9, ._52jb, ._52ja, ._5j35, ._rnk, ._24u0, ._1g06, ._14ye, .fcb, ._56cz._56c_, ._1gk_, ._55fj, ._45fu, ._18qg, ._1_ac, ._5aga, ._5ag9, ._537a, .acy, ._5ro_, ._6-l ._2us7, ._4mp, ._2b08, ._14v5 ._14v8, ._1440, ._1442, ._1448, ._4ks_, textarea, ._24pi, ._4en9, ._1kb, ._5p7j, ._2klz, ._5780, ._5781, ._5782, ._3u9u, ._3u9_, ._3u9s, ._1hcx, ._2066, ._1_-1, ._cv_, ._1nbx, ._2cuh, ._4ms9, ._4ms5, ._4ms6, ._31b4, ._31b5, ._5q_r, ._idb, ._27vp, ._4nwe, ._4nw9, ._27vi, .appCenterAppInfo, .appCenterPermissions, ._3c9l, ._3c9m, ._4jn_, ._32qt, ._3mom, ._3moo, ._-7o, ._d00, ._d01, ._559g, ._2new, .appCenterCategorySelectorButton, div.sharerSelector, .footer, .mentions-input, .mentions-placeholder, .largeStatusBox .placeHolder, .fcw, ._5-7t, .fcl, ._4qas, .thread-title, .title, ._46pa, ._336p, ._1rrd, ._2om4, ._3m1m, ._2om2, ._5n_e, .appListExplanation, ._5yt8, ._8he, ._2luw, ._5rgs, h1, h2, h3, h4, h5, h6 { color: #fff !important; }
+[style*="color"], body, input, ._42rv, ._4qau, ._dwm .descArea, ._eu5, ._1tcc, ._3g9-, ._29z_, ._3xz7, ._ib-, ._3bg5 ._56dq, ._477i, ._2vxk, .touched *, ._1_yj, ._1_yl, ._4pj9, ._z-z, ._z-v, ._1e8d, ._36nl, ._36nm, ._2_11, ._2_rf, ._2ip_, ._403p, ._5xu2, ._3ml8, ._3mla, ._50vk, ._1m2u, ._31y7, ._4kcb, ._1lf6, ._1lf5, ._1lf4, ._1hiz, ._xod, ._5ag5, ._43mh, .touch .btn, p, span, .fcg, button, ._52j9, ._52jb, ._52ja, ._5j35, ._rnk, ._24u0, ._1g06, ._14ye, .fcb, ._56cz._56c_, ._1gk_, ._55fj, ._45fu, ._18qg, ._1_ac, ._529p, ._5aga, ._5ag9, ._537a, .acy, ._5ro_, ._6-l ._2us7, ._4mp, ._2b08, ._14v5 ._14v8, ._1440, ._1442, ._1448, ._4ks_, textarea, ._24pi, ._4en9, ._1kb, ._5p7j, ._2klz, ._5780, ._5781, ._5782, ._3u9u, ._3u9_, ._3u9s, ._1hcx, ._2066, ._1_-1, ._cv_, ._1nbx, ._2cuh, ._4ms9, ._4ms5, ._4ms6, ._31b4, ._31b5, ._5q_r, ._idb, ._27vp, ._4nwe, ._4nw9, ._27vi, .appCenterAppInfo, .appCenterPermissions, ._3c9l, ._3c9m, ._4jn_, ._32qt, ._3mom, ._3moo, ._-7o, ._d00, ._d01, ._559g, ._2new, .appCenterCategorySelectorButton, div.sharerSelector, .footer, .mentions-input, .mentions-placeholder, .largeStatusBox .placeHolder, .fcw, ._5-7t, .fcl, ._4qas, .thread-title, .title, ._46pa, ._336p, ._1rrd, ._2om4, ._3m1m, ._2om2, ._5n_e, .appListExplanation, ._5yt8, ._8he, ._2luw, ._5rgs, h1, h2, h3, h4, h5, h6 { color: #fff !important; }
strong > a, ._15ks ._2q8z._2q8z, ._1e3e { color: #5d86dd !important; }
@@ -8,7 +8,7 @@ a, ._5fpq { color: #eee !important; }
#viewport { background: rgba(0, 0, 0, 0.1) !important; }
-body, #root, #header, [style*="background-color"], ._1upc, input, ._2f9r, ._59e9, ._5pz4, ._5lp4, ._5lp5, .container, .subpage, ._5n_f, #static_templates, ._22_8, ._1t4h, ._uoq, ._6-l ._2us7, ._6-l ._6-p, ._333v, div.sharerSelector, ._1g05, .acy, ._51-g, ._533c, ._ib-, .sharerAttachmentEmpty, .sharerBottomWrapper, ._3bg5 ._56do, ._5hfh, ._52e-, .mQuestionsPollResultsBar, ._5hoc, ._5oxw, ._32_4, ._1hiz, .tlBody, #timelineBody, .timelineX, .timeline, .feed, .tlPrelude, .tlFeedPlaceholder, ._4_d0, .al, ._1gkq, ._5c5b, ._1qxg, ._5luf, ._2new, ._cld, ._3zvb, ._2nk0, .btnD, .btnI, ._11ub, ._5p7j, ._55wm, ._5rgs, ._5xuj, ._1sv1, ._45fu, ._18qg, ._1_ac, ._5w3g, ._3e18, ._5q_r, ._5yt8, ._idb, ._2ip_, ._f6s, ._2l5v, ._8i2, ._kr5, ._2q7u, ._2q7v, ._577z, ._2u4w, ._3u9p, ._3u9t, ._cw4, ._5_y-, ._5_y_, ._5_z3, ._cwy, ._5_z0, ._5_z1, ._5_z2, ._2mtc, ._206a, ._1_-1, ._1ybg, .appCenterCategorySelectorButton, ._5c9u, div._5y57::before, ._59f6._55so::before, .structuredPublisher, ._94v, ._vqv, ._5lp5, ._55wm, ._2om3, ._2ol-, ._1f9d, ._vee, ._31a-, ._3r8b, ._3r9d, ._5vq5, .acw, ._4_xl, ._1p70, ._1p70, ._1ih_, ._51v6, ._u2c, ._484w, ._3ils, ._rm7, ._32qk, ._d01, ._2y60, ._5fu3, ._2foa, ._2y5_, ._38o9, ._1kb, .mAppCenterFatLabel, ._3f50, .mentions-placeholder, .mentions, .mentions-shadow, .mentions-measurer, .acg, ._59tu, ._52z5, ._4l9b, ._4gj3, .groupChromeView, ._i3g, ._3jcf, .error, ._uww, textarea, ._15n_, ._skt, ._5f28, ._14_j, ._3bg5, ._53_-, ._52x1, ._35au, ._cwy, ._1rfn ._1rfk ._4vc-, ._1rfk, ._1rfk ._2v9s, ._301x { background: transparent !important; }
+body, #root, #header, [style*="background-color"], ._1upc, input, ._2f9r, ._59e9, ._5pz4, ._5lp4, ._5lp5, .container, .subpage, ._5n_f, #static_templates, ._22_8, ._1t4h, ._uoq, ._6-l ._2us7, ._6-l ._6-p, ._333v, div.sharerSelector, ._529j, ._1g05, .acy, ._51-g, ._533c, ._ib-, .sharerAttachmentEmpty, .sharerBottomWrapper, ._3bg5 ._56do, ._5hfh, ._52e-, .mQuestionsPollResultsBar, ._5hoc, ._5oxw, ._32_4, ._1hiz, .tlBody, #timelineBody, .timelineX, .timeline, .feed, .tlPrelude, .tlFeedPlaceholder, ._4_d0, .al, ._1gkq, ._5c5b, ._1qxg, ._5luf, ._2new, ._cld, ._3zvb, ._2nk0, .btnD, .btnI, ._11ub, ._5p7j, ._55wm, ._5rgs, ._5xuj, ._1sv1, ._45fu, ._18qg, ._1_ac, ._5w3g, ._3e18, ._5q_r, ._5yt8, ._idb, ._2ip_, ._f6s, ._2l5v, ._8i2, ._kr5, ._2q7u, ._2q7v, ._577z, ._2u4w, ._3u9p, ._3u9t, ._cw4, ._5_y-, ._5_y_, ._5_z3, ._cwy, ._5_z0, ._5_z1, ._5_z2, ._2mtc, ._206a, ._1_-1, ._1ybg, .appCenterCategorySelectorButton, ._5c9u, div._5y57::before, ._59f6._55so::before, .structuredPublisher, ._94v, ._vqv, ._5lp5, ._55wm, ._2om3, ._2ol-, ._1f9d, ._vee, ._31a-, ._3r8b, ._3r9d, ._5vq5, .acw, ._4_xl, ._1p70, ._1p70, ._1ih_, ._51v6, ._u2c, ._484w, ._3ils, ._rm7, ._32qk, ._d01, ._2y60, ._5fu3, ._2foa, ._2y5_, ._38o9, ._1kb, .mAppCenterFatLabel, ._3f50, .mentions-placeholder, .mentions, .mentions-shadow, .mentions-measurer, .acg, ._59tu, ._52z5, ._4l9b, ._4gj3, .groupChromeView, ._i3g, ._3jcf, .error, ._uww, textarea, ._15n_, ._skt, ._5f28, ._14_j, ._3bg5, ._53_-, ._52x1, ._35au, ._cwy, ._1rfn ._1rfk ._4vc-, ._1rfk, ._1rfk ._2v9s, ._301x { background: transparent !important; }
._31nf, ._2v9s, ._d4i, article._55wo, ._10c_, ._2jl2, ._55wo { background: rgba(0, 0, 0, 0.25) !important; }
@@ -28,6 +28,8 @@ button:not([style*=image]), button::before, .touch ._56bt, ._56be::before, .btnS
._220g, ._1_y8:after, ._2zh4::before, ._2ip_ ._2zh4::before, ._2ip_ ._15kk::before, ._2ip_ ._15kk + ._4u3j::before, ._58a0:before, ._43mh::before, ._43mh::after, ._1_-1::before, ._1kmv:after, ._1_ac:before { background: rgba(255, 255, 255, 0.3) !important; }
+button ._v89 ._54k8._1fl1 { background: #5d86dd !important; }
+
._15kl::before, ._37fd .inlineComposerButton, ._1hb:before, ._5j35::after, ._2k4b, ._3to7, ._4nw8 { border-left: 1px solid rgba(255, 255, 255, 0.3) !important; }
._4_d1 { border-right: 1px solid rgba(255, 255, 255, 0.3) !important; }
diff --git a/app/src/main/assets/css/themes/material_light.css b/app/src/main/assets/css/themes/material_light.css
index 7026cfe9..3e3ff551 100644
--- a/app/src/main/assets/css/themes/material_light.css
+++ b/app/src/main/assets/css/themes/material_light.css
@@ -1,4 +1,4 @@
-[style*="color"], body, input, ._42rv, ._4qau, ._dwm .descArea, ._eu5, ._1tcc, ._3g9-, ._29z_, ._3xz7, ._ib-, ._3bg5 ._56dq, ._477i, ._2vxk, .touched *, ._1_yj, ._1_yl, ._4pj9, ._z-z, ._z-v, ._1e8d, ._36nl, ._36nm, ._2_11, ._2_rf, ._2ip_, ._403p, ._5xu2, ._3ml8, ._3mla, ._50vk, ._1m2u, ._31y7, ._4kcb, ._1lf6, ._1lf5, ._1lf4, ._1hiz, ._xod, ._5ag5, ._43mh, .touch .btn, p, span, .fcg, button, ._52j9, ._52jb, ._52ja, ._5j35, ._rnk, ._24u0, ._1g06, ._14ye, .fcb, ._56cz._56c_, ._1gk_, ._55fj, ._45fu, ._18qg, ._1_ac, ._5aga, ._5ag9, ._537a, .acy, ._5ro_, ._6-l ._2us7, ._4mp, ._2b08, ._14v5 ._14v8, ._1440, ._1442, ._1448, ._4ks_, textarea, ._24pi, ._4en9, ._1kb, ._5p7j, ._2klz, ._5780, ._5781, ._5782, ._3u9u, ._3u9_, ._3u9s, ._1hcx, ._2066, ._1_-1, ._cv_, ._1nbx, ._2cuh, ._4ms9, ._4ms5, ._4ms6, ._31b4, ._31b5, ._5q_r, ._idb, ._27vp, ._4nwe, ._4nw9, ._27vi, .appCenterAppInfo, .appCenterPermissions, ._3c9l, ._3c9m, ._4jn_, ._32qt, ._3mom, ._3moo, ._-7o, ._d00, ._d01, ._559g, ._2new, .appCenterCategorySelectorButton, div.sharerSelector, .footer, .mentions-input, .mentions-placeholder, .largeStatusBox .placeHolder, .fcw, ._5-7t, .fcl, ._4qas, .thread-title, .title, ._46pa, ._336p, ._1rrd, ._2om4, ._3m1m, ._2om2, ._5n_e, .appListExplanation, ._5yt8, ._8he, ._2luw, ._5rgs, h1, h2, h3, h4, h5, h6 { color: #000 !important; }
+[style*="color"], body, input, ._42rv, ._4qau, ._dwm .descArea, ._eu5, ._1tcc, ._3g9-, ._29z_, ._3xz7, ._ib-, ._3bg5 ._56dq, ._477i, ._2vxk, .touched *, ._1_yj, ._1_yl, ._4pj9, ._z-z, ._z-v, ._1e8d, ._36nl, ._36nm, ._2_11, ._2_rf, ._2ip_, ._403p, ._5xu2, ._3ml8, ._3mla, ._50vk, ._1m2u, ._31y7, ._4kcb, ._1lf6, ._1lf5, ._1lf4, ._1hiz, ._xod, ._5ag5, ._43mh, .touch .btn, p, span, .fcg, button, ._52j9, ._52jb, ._52ja, ._5j35, ._rnk, ._24u0, ._1g06, ._14ye, .fcb, ._56cz._56c_, ._1gk_, ._55fj, ._45fu, ._18qg, ._1_ac, ._529p, ._5aga, ._5ag9, ._537a, .acy, ._5ro_, ._6-l ._2us7, ._4mp, ._2b08, ._14v5 ._14v8, ._1440, ._1442, ._1448, ._4ks_, textarea, ._24pi, ._4en9, ._1kb, ._5p7j, ._2klz, ._5780, ._5781, ._5782, ._3u9u, ._3u9_, ._3u9s, ._1hcx, ._2066, ._1_-1, ._cv_, ._1nbx, ._2cuh, ._4ms9, ._4ms5, ._4ms6, ._31b4, ._31b5, ._5q_r, ._idb, ._27vp, ._4nwe, ._4nw9, ._27vi, .appCenterAppInfo, .appCenterPermissions, ._3c9l, ._3c9m, ._4jn_, ._32qt, ._3mom, ._3moo, ._-7o, ._d00, ._d01, ._559g, ._2new, .appCenterCategorySelectorButton, div.sharerSelector, .footer, .mentions-input, .mentions-placeholder, .largeStatusBox .placeHolder, .fcw, ._5-7t, .fcl, ._4qas, .thread-title, .title, ._46pa, ._336p, ._1rrd, ._2om4, ._3m1m, ._2om2, ._5n_e, .appListExplanation, ._5yt8, ._8he, ._2luw, ._5rgs, h1, h2, h3, h4, h5, h6 { color: #000 !important; }
strong > a, ._15ks ._2q8z._2q8z, ._1e3e { color: #3b5998 !important; }
@@ -8,7 +8,7 @@ a, ._5fpq { color: #111 !important; }
#viewport { background: #fafafa !important; }
-body, #root, #header, [style*="background-color"], ._1upc, input, ._2f9r, ._59e9, ._5pz4, ._5lp4, ._5lp5, .container, .subpage, ._5n_f, #static_templates, ._22_8, ._1t4h, ._uoq, ._6-l ._2us7, ._6-l ._6-p, ._333v, div.sharerSelector, ._1g05, .acy, ._51-g, ._533c, ._ib-, .sharerAttachmentEmpty, .sharerBottomWrapper, ._3bg5 ._56do, ._5hfh, ._52e-, .mQuestionsPollResultsBar, ._5hoc, ._5oxw, ._32_4, ._1hiz, .tlBody, #timelineBody, .timelineX, .timeline, .feed, .tlPrelude, .tlFeedPlaceholder, ._4_d0, .al, ._1gkq, ._5c5b, ._1qxg, ._5luf, ._2new, ._cld, ._3zvb, ._2nk0, .btnD, .btnI, ._11ub, ._5p7j, ._55wm, ._5rgs, ._5xuj, ._1sv1, ._45fu, ._18qg, ._1_ac, ._5w3g, ._3e18, ._5q_r, ._5yt8, ._idb, ._2ip_, ._f6s, ._2l5v, ._8i2, ._kr5, ._2q7u, ._2q7v, ._577z, ._2u4w, ._3u9p, ._3u9t, ._cw4, ._5_y-, ._5_y_, ._5_z3, ._cwy, ._5_z0, ._5_z1, ._5_z2, ._2mtc, ._206a, ._1_-1, ._1ybg, .appCenterCategorySelectorButton, ._5c9u, div._5y57::before, ._59f6._55so::before, .structuredPublisher, ._94v, ._vqv, ._5lp5, ._55wm, ._2om3, ._2ol-, ._1f9d, ._vee, ._31a-, ._3r8b, ._3r9d, ._5vq5, .acw, ._4_xl, ._1p70, ._1p70, ._1ih_, ._51v6, ._u2c, ._484w, ._3ils, ._rm7, ._32qk, ._d01, ._2y60, ._5fu3, ._2foa, ._2y5_, ._38o9, ._1kb, .mAppCenterFatLabel, ._3f50, .mentions-placeholder, .mentions, .mentions-shadow, .mentions-measurer, .acg, ._59tu, ._52z5, ._4l9b, ._4gj3, .groupChromeView, ._i3g, ._3jcf, .error, ._uww, textarea, ._15n_, ._skt, ._5f28, ._14_j, ._3bg5, ._53_-, ._52x1, ._35au, ._cwy, ._1rfn ._1rfk ._4vc-, ._1rfk, ._1rfk ._2v9s, ._301x { background: #fafafa !important; }
+body, #root, #header, [style*="background-color"], ._1upc, input, ._2f9r, ._59e9, ._5pz4, ._5lp4, ._5lp5, .container, .subpage, ._5n_f, #static_templates, ._22_8, ._1t4h, ._uoq, ._6-l ._2us7, ._6-l ._6-p, ._333v, div.sharerSelector, ._529j, ._1g05, .acy, ._51-g, ._533c, ._ib-, .sharerAttachmentEmpty, .sharerBottomWrapper, ._3bg5 ._56do, ._5hfh, ._52e-, .mQuestionsPollResultsBar, ._5hoc, ._5oxw, ._32_4, ._1hiz, .tlBody, #timelineBody, .timelineX, .timeline, .feed, .tlPrelude, .tlFeedPlaceholder, ._4_d0, .al, ._1gkq, ._5c5b, ._1qxg, ._5luf, ._2new, ._cld, ._3zvb, ._2nk0, .btnD, .btnI, ._11ub, ._5p7j, ._55wm, ._5rgs, ._5xuj, ._1sv1, ._45fu, ._18qg, ._1_ac, ._5w3g, ._3e18, ._5q_r, ._5yt8, ._idb, ._2ip_, ._f6s, ._2l5v, ._8i2, ._kr5, ._2q7u, ._2q7v, ._577z, ._2u4w, ._3u9p, ._3u9t, ._cw4, ._5_y-, ._5_y_, ._5_z3, ._cwy, ._5_z0, ._5_z1, ._5_z2, ._2mtc, ._206a, ._1_-1, ._1ybg, .appCenterCategorySelectorButton, ._5c9u, div._5y57::before, ._59f6._55so::before, .structuredPublisher, ._94v, ._vqv, ._5lp5, ._55wm, ._2om3, ._2ol-, ._1f9d, ._vee, ._31a-, ._3r8b, ._3r9d, ._5vq5, .acw, ._4_xl, ._1p70, ._1p70, ._1ih_, ._51v6, ._u2c, ._484w, ._3ils, ._rm7, ._32qk, ._d01, ._2y60, ._5fu3, ._2foa, ._2y5_, ._38o9, ._1kb, .mAppCenterFatLabel, ._3f50, .mentions-placeholder, .mentions, .mentions-shadow, .mentions-measurer, .acg, ._59tu, ._52z5, ._4l9b, ._4gj3, .groupChromeView, ._i3g, ._3jcf, .error, ._uww, textarea, ._15n_, ._skt, ._5f28, ._14_j, ._3bg5, ._53_-, ._52x1, ._35au, ._cwy, ._1rfn ._1rfk ._4vc-, ._1rfk, ._1rfk ._2v9s, ._301x { background: #fafafa !important; }
._31nf, ._2v9s, ._d4i, article._55wo, ._10c_, ._2jl2, ._55wo { background: #fff !important; }
@@ -28,6 +28,8 @@ button:not([style*=image]), button::before, .touch ._56bt, ._56be::before, .btnS
._220g, ._1_y8:after, ._2zh4::before, ._2ip_ ._2zh4::before, ._2ip_ ._15kk::before, ._2ip_ ._15kk + ._4u3j::before, ._58a0:before, ._43mh::before, ._43mh::after, ._1_-1::before, ._1kmv:after, ._1_ac:before { background: rgba(0, 0, 0, 0.3) !important; }
+button ._v89 ._54k8._1fl1 { background: #3b5998 !important; }
+
._15kl::before, ._37fd .inlineComposerButton, ._1hb:before, ._5j35::after, ._2k4b, ._3to7, ._4nw8 { border-left: 1px solid rgba(0, 0, 0, 0.3) !important; }
._4_d1 { border-right: 1px solid rgba(0, 0, 0, 0.3) !important; }
diff --git a/app/src/main/kotlin/com/pitchedapps/frost/activities/BaseMainActivity.kt b/app/src/main/kotlin/com/pitchedapps/frost/activities/BaseMainActivity.kt
index 80d248bc..8f932a94 100644
--- a/app/src/main/kotlin/com/pitchedapps/frost/activities/BaseMainActivity.kt
+++ b/app/src/main/kotlin/com/pitchedapps/frost/activities/BaseMainActivity.kt
@@ -58,6 +58,7 @@ import com.pitchedapps.frost.facebook.FbCookie
import com.pitchedapps.frost.facebook.FbItem
import com.pitchedapps.frost.facebook.PROFILE_PICTURE_URL
import com.pitchedapps.frost.fragments.BaseFragment
+import com.pitchedapps.frost.parsers.FrostSearch
import com.pitchedapps.frost.parsers.SearchParser
import com.pitchedapps.frost.utils.*
import com.pitchedapps.frost.utils.iab.FrostBilling
@@ -127,6 +128,7 @@ abstract class BaseMainActivity : BaseActivity(), MainActivityContract,
onCreateBilling()
}
+
fun tabsForEachView(action: (position: Int, view: BadgedIcon) -> Unit) {
(0 until tabs.tabCount).asSequence().forEach { i ->
action(i, tabs.getTabAt(i)!!.customView as BadgedIcon)
@@ -193,7 +195,7 @@ abstract class BaseMainActivity : BaseActivity(), MainActivityContract,
-3L -> launchNewTask(LoginActivity::class.java, clearStack = false)
-4L -> launchNewTask(SelectorActivity::class.java, cookies(), false)
else -> {
- FbCookie.switchUser(profile.identifier, { refreshAll() })
+ FbCookie.switchUser(profile.identifier, this@BaseMainActivity::refreshAll)
tabsForEachView { _, view -> view.badgeText = null }
}
}
@@ -248,7 +250,7 @@ abstract class BaseMainActivity : BaseActivity(), MainActivityContract,
onClick { _ -> onClick(); false }
}
- fun refreshAll() {
+ private fun refreshAll() {
fragmentSubject.onNext(REQUEST_REFRESH)
}
@@ -266,8 +268,8 @@ abstract class BaseMainActivity : BaseActivity(), MainActivityContract,
runOnUiThread { searchView?.results = results }
else
doAsync {
- val data = SearchParser.query(query) ?: return@doAsync
- val items = data.map { SearchItem(it.href, it.title, it.description) }.toMutableList()
+ val data = SearchParser.query(FbCookie.webCookie, query)?.data?.results ?: return@doAsync
+ val items = data.map(FrostSearch::toSearchItem).toMutableList()
if (items.isNotEmpty())
items.add(SearchItem("${FbItem._SEARCH.url}?q=$query", string(R.string.show_all_results), iicon = null))
searchViewCache.put(query, items)
diff --git a/app/src/main/kotlin/com/pitchedapps/frost/activities/LoginActivity.kt b/app/src/main/kotlin/com/pitchedapps/frost/activities/LoginActivity.kt
index 0d6cce07..e2f7a3d2 100644
--- a/app/src/main/kotlin/com/pitchedapps/frost/activities/LoginActivity.kt
+++ b/app/src/main/kotlin/com/pitchedapps/frost/activities/LoginActivity.kt
@@ -25,10 +25,9 @@ import com.pitchedapps.frost.facebook.FbCookie
import com.pitchedapps.frost.facebook.PROFILE_PICTURE_URL
import com.pitchedapps.frost.utils.*
import com.pitchedapps.frost.web.LoginWebView
-import io.reactivex.Observable
+import io.reactivex.Single
import io.reactivex.android.schedulers.AndroidSchedulers
import io.reactivex.functions.BiFunction
-import io.reactivex.internal.operators.single.SingleToObservable
import io.reactivex.subjects.SingleSubject
@@ -37,18 +36,18 @@ import io.reactivex.subjects.SingleSubject
*/
class LoginActivity : BaseActivity() {
- val toolbar: Toolbar by bindView(R.id.toolbar)
- val web: LoginWebView by bindView(R.id.login_webview)
- val swipeRefresh: SwipeRefreshLayout by bindView(R.id.swipe_refresh)
- val textview: AppCompatTextView by bindView(R.id.textview)
- 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)
- val profileObservable = SingleSubject.create<Boolean>()
- val usernameObservable = SingleSubject.create<String>()
- lateinit var profileLoader: RequestManager
+ private val profileSubject = SingleSubject.create<Boolean>()
+ private val usernameSubject = SingleSubject.create<String>()
+ private lateinit var profileLoader: RequestManager
// Helper to set and enable swipeRefresh
- var refresh: Boolean
+ private var refresh: Boolean
get() = swipeRefresh.isRefreshing
set(value) {
if (value) swipeRefresh.isEnabled = true
@@ -73,10 +72,12 @@ class LoginActivity : BaseActivity() {
profileLoader = Glide.with(profile)
}
- fun loadInfo(cookie: CookieModel) {
+ private fun loadInfo(cookie: CookieModel) {
refresh = true
- Observable.zip(SingleToObservable(profileObservable), SingleToObservable(usernameObservable),
- BiFunction<Boolean, String, Pair<Boolean, String>> { foundImage, name -> Pair(foundImage, name) })
+ Single.zip<Boolean, String, Pair<Boolean, String>>(
+ profileSubject,
+ usernameSubject,
+ BiFunction(::Pair))
.observeOn(AndroidSchedulers.mainThread()).subscribe { (foundImage, name) ->
refresh = false
if (!foundImage) {
@@ -85,7 +86,11 @@ class LoginActivity : BaseActivity() {
}
textview.text = String.format(getString(R.string.welcome), name)
textview.fadeIn()
- frostAnswers { logLogin(LoginEvent().putMethod("frost_browser").putSuccess(true)) }
+ frostAnswers {
+ logLogin(LoginEvent()
+ .putMethod("frost_browser")
+ .putSuccess(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
@@ -102,23 +107,23 @@ class LoginActivity : BaseActivity() {
}
- fun loadProfile(id: Long) {
+ private fun loadProfile(id: Long) {
profileLoader.load(PROFILE_PICTURE_URL(id)).withRoundIcon().listener(object : RequestListener<Drawable> {
override fun onResourceReady(resource: Drawable?, model: Any?, target: Target<Drawable>?, dataSource: DataSource?, isFirstResource: Boolean): Boolean {
- profileObservable.onSuccess(true)
+ profileSubject.onSuccess(true)
return false
}
override fun onLoadFailed(e: GlideException?, model: Any?, target: Target<Drawable>?, isFirstResource: Boolean): Boolean {
e.logFrostAnswers("Profile loading exception")
- profileObservable.onSuccess(false)
+ profileSubject.onSuccess(false)
return false
}
}).into(profile)
}
- fun loadUsername(cookie: CookieModel) {
- cookie.fetchUsername { usernameObservable.onSuccess(it) }
+ private fun loadUsername(cookie: CookieModel) {
+ cookie.fetchUsername(usernameSubject::onSuccess)
}
override fun backConsumer(): Boolean {
@@ -129,4 +134,14 @@ class LoginActivity : BaseActivity() {
return false
}
+ override fun onResume() {
+ super.onResume()
+ web.resumeTimers()
+ }
+
+ override fun onPause() {
+ web.pauseTimers()
+ super.onPause()
+ }
+
} \ No newline at end of file
diff --git a/app/src/main/kotlin/com/pitchedapps/frost/contracts/ActivityContract.kt b/app/src/main/kotlin/com/pitchedapps/frost/contracts/ActivityContract.kt
index e46a4bfb..14d7ae09 100644
--- a/app/src/main/kotlin/com/pitchedapps/frost/contracts/ActivityContract.kt
+++ b/app/src/main/kotlin/com/pitchedapps/frost/contracts/ActivityContract.kt
@@ -1,5 +1,6 @@
package com.pitchedapps.frost.contracts
+import com.pitchedapps.frost.dbflow.CookieModel
import io.reactivex.subjects.PublishSubject
/**
diff --git a/app/src/main/kotlin/com/pitchedapps/frost/facebook/FbRegex.kt b/app/src/main/kotlin/com/pitchedapps/frost/facebook/FbRegex.kt
index 39e8c467..8d625582 100644
--- a/app/src/main/kotlin/com/pitchedapps/frost/facebook/FbRegex.kt
+++ b/app/src/main/kotlin/com/pitchedapps/frost/facebook/FbRegex.kt
@@ -13,8 +13,17 @@ package com.pitchedapps.frost.facebook
* 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
*/
-val FB_USER_MATCHER: Regex by lazy { Regex("c_user=([0-9]*);") } \ No newline at end of file
+val FB_USER_MATCHER: Regex by lazy { Regex("c_user=([0-9]*);") }
+
+val FB_EPOCH_MATCHER: Regex by lazy { Regex(":([0-9]+)") }
+val FB_NOTIF_ID_MATCHER: Regex by lazy { Regex("notif_id\":([0-9]+)") }
+val FB_MESSAGE_NOTIF_ID_MATCHER: Regex by lazy { Regex("[thread|user]_fbid_([0-9]+)") }
+val FB_CSS_URL_MATCHER: Regex by lazy { Regex("url\\([\"|'](.*?)[\"|']\\)") }
+
+operator fun MatchResult?.get(groupIndex: Int) = this?.groupValues?.get(groupIndex)
+
diff --git a/app/src/main/kotlin/com/pitchedapps/frost/facebook/FbRequest.kt b/app/src/main/kotlin/com/pitchedapps/frost/facebook/FbRequest.kt
index 428043a0..2fa20917 100644
--- a/app/src/main/kotlin/com/pitchedapps/frost/facebook/FbRequest.kt
+++ b/app/src/main/kotlin/com/pitchedapps/frost/facebook/FbRequest.kt
@@ -11,7 +11,13 @@ import org.apache.commons.text.StringEscapeUtils
/**
* Created by Allan Wang on 21/12/17.
*/
-data class RequestAuth(val userId: Long = -1, val cookie: String = "", val fb_dtsg: String = "")
+data class RequestAuth(val userId: Long = -1,
+ val cookie: String = "",
+ val fb_dtsg: String = "",
+ val rev: String = "") {
+ val isValid
+ get() = userId > 0 && cookie.isNotEmpty() && fb_dtsg.isNotEmpty() && rev.isNotEmpty()
+}
private val client: OkHttpClient by lazy {
val builder = OkHttpClient.Builder()
@@ -21,7 +27,7 @@ private val client: OkHttpClient by lazy {
builder.build()
}
-private fun List<Pair<String, Any?>>.toForm(): RequestBody {
+private fun List<Pair<String, Any?>>.toForm(): FormBody {
val builder = FormBody.Builder()
forEach { (key, value) ->
val v = value?.toString() ?: ""
@@ -30,6 +36,12 @@ private fun List<Pair<String, Any?>>.toForm(): RequestBody {
return builder.build()
}
+private fun List<Pair<String, Any?>>.withEmptyData(vararg key: String): List<Pair<String, Any?>> {
+ val newList = toMutableList()
+ newList.addAll(key.map { it to null })
+ return newList
+}
+
private fun String.requestBuilder() = Request.Builder()
.header("Cookie", this)
.header("User-Agent", USER_AGENT_BASIC)
@@ -38,25 +50,33 @@ private fun String.requestBuilder() = Request.Builder()
private fun Request.Builder.call() = client.newCall(build())
-fun Pair<Long, String>.getAuth(): RequestAuth? {
+fun Pair<Long, String>.getAuth(): RequestAuth {
val (userId, cookie) = this
+ var auth = RequestAuth(userId, cookie)
val call = cookie.requestBuilder()
- .url(FB_URL_BASE)
+ .url("https://touch.facebook.com")
.get()
.call()
call.execute().body()?.charStream()?.useLines {
it.forEach {
val text = StringEscapeUtils.unescapeEcmaScript(it)
- val result = FB_DTSG_MATCHER.find(text)
- val fb_dtsg = result?.groupValues?.get(1)
+ val fb_dtsg = FB_DTSG_MATCHER.find(text)[1]
if (fb_dtsg != null) {
L.d(null, "fb_dtsg for $userId: $fb_dtsg")
- return RequestAuth(userId, cookie, fb_dtsg)
+ auth = auth.copy(fb_dtsg = fb_dtsg)
+ if (auth.isValid) return auth
+ }
+
+ val rev = FB_REV_MATCHER.find(text)[1]
+ if (rev != null) {
+ L.d(null, "rev for $userId: $rev")
+ auth = auth.copy(rev = rev)
+ if (auth.isValid) return auth
}
}
}
- return null
+ return auth
}
fun RequestAuth.markNotificationRead(notifId: Long): Call {
@@ -65,13 +85,9 @@ fun RequestAuth.markNotificationRead(notifId: Long): Call {
"click_type" to "notification_click",
"id" to notifId,
"target_id" to "null",
- "m_sess" to null,
"fb_dtsg" to fb_dtsg,
- "__dyn" to null,
- "__req" to null,
- "__ajax__" to null,
"__user" to userId
- )
+ ).withEmptyData("m_sess", "__dyn", "__req", "__ajax__")
return cookie.requestBuilder()
.url("${FB_URL_BASE}a/jewel_notifications_log.php")
@@ -96,3 +112,18 @@ fun RequestAuth.markNotificationsRead(vararg notifId: Long) = zip<Long, Boolean,
response.body()?.charStream()?.read(buffer) ?: return@zip false
!buffer.toString().contains("error")
}
+
+/**
+ * Execute the call and attempt to check validity
+ */
+fun Call.executeAndCheck(): Boolean {
+ val body = execute().body() ?: return false
+ var empty = true
+ body.charStream().useLines {
+ it.forEach {
+ if (empty && it.isNotEmpty()) empty = false
+ if (it.contains("error")) return true
+ }
+ }
+ return !empty
+}
diff --git a/app/src/main/kotlin/com/pitchedapps/frost/fragments/FragmentBase.kt b/app/src/main/kotlin/com/pitchedapps/frost/fragments/FragmentBase.kt
index 498164c0..58d9ebd4 100644
--- a/app/src/main/kotlin/com/pitchedapps/frost/fragments/FragmentBase.kt
+++ b/app/src/main/kotlin/com/pitchedapps/frost/fragments/FragmentBase.kt
@@ -13,7 +13,9 @@ import com.pitchedapps.frost.R
import com.pitchedapps.frost.contracts.DynamicUiContract
import com.pitchedapps.frost.contracts.FrostContentParent
import com.pitchedapps.frost.contracts.MainActivityContract
+import com.pitchedapps.frost.dbflow.CookieModel
import com.pitchedapps.frost.enums.FeedSort
+import com.pitchedapps.frost.facebook.FbCookie
import com.pitchedapps.frost.facebook.FbItem
import com.pitchedapps.frost.parsers.FrostParser
import com.pitchedapps.frost.utils.*
@@ -28,6 +30,9 @@ import org.jetbrains.anko.toast
/**
* Created by Allan Wang on 2017-11-07.
+ *
+ * All fragments pertaining to the main view
+ * Must be attached to activities implementing [MainActivityContract]
*/
abstract class BaseFragment : Fragment(), FragmentContract, DynamicUiContract {
@@ -59,6 +64,12 @@ abstract class BaseFragment : Fragment(), FragmentContract, DynamicUiContract {
protected abstract val layoutRes: Int
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ if (context !is MainActivityContract)
+ throw IllegalArgumentException("${this::class.java.simpleName} is not attached to a context implementing MainActivityContract")
+ }
+
override final fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
val view = inflater.inflate(layoutRes, container, false)
val content = view as? FrostContentParent
@@ -162,7 +173,7 @@ abstract class BaseFragment : Fragment(), FragmentContract, DynamicUiContract {
override fun onTabClick(): Unit = content?.core?.onTabClicked() ?: Unit
}
-abstract class RecyclerFragment<T, Item : IItem<*, *>> : BaseFragment(), RecyclerContentContract {
+abstract class RecyclerFragment<T : Any, Item : IItem<*, *>> : BaseFragment(), RecyclerContentContract {
override val layoutRes: Int = R.layout.view_content_recycler
@@ -199,7 +210,7 @@ abstract class RecyclerFragment<T, Item : IItem<*, *>> : BaseFragment(), Recycle
progress(10)
val doc = frostJsoup(baseUrl)
progress(60)
- val data = parser.parse(doc)
+ val data = parser.parse(FbCookie.webCookie, doc)
if (data == null) {
context?.toast(R.string.error_generic)
L.eThrow("RecyclerFragment failed for ${baseEnum.name}")
@@ -207,7 +218,7 @@ abstract class RecyclerFragment<T, Item : IItem<*, *>> : BaseFragment(), Recycle
return@doAsync callback(false)
}
progress(80)
- val items = toItems(data)
+ val items = toItems(data.data)
progress(97)
adapter.setNewList(items)
}
diff --git a/app/src/main/kotlin/com/pitchedapps/frost/fragments/FragmentContract.kt b/app/src/main/kotlin/com/pitchedapps/frost/fragments/FragmentContract.kt
index 62b1de33..00429730 100644
--- a/app/src/main/kotlin/com/pitchedapps/frost/fragments/FragmentContract.kt
+++ b/app/src/main/kotlin/com/pitchedapps/frost/fragments/FragmentContract.kt
@@ -5,6 +5,7 @@ import com.pitchedapps.frost.contracts.FrostContentContainer
import com.pitchedapps.frost.contracts.FrostContentCore
import com.pitchedapps.frost.contracts.FrostContentParent
import com.pitchedapps.frost.contracts.MainActivityContract
+import com.pitchedapps.frost.dbflow.CookieModel
import com.pitchedapps.frost.views.FrostRecyclerView
import io.reactivex.disposables.Disposable
diff --git a/app/src/main/kotlin/com/pitchedapps/frost/parsers/FrostParser.kt b/app/src/main/kotlin/com/pitchedapps/frost/parsers/FrostParser.kt
index 186633e5..016f33e8 100644
--- a/app/src/main/kotlin/com/pitchedapps/frost/parsers/FrostParser.kt
+++ b/app/src/main/kotlin/com/pitchedapps/frost/parsers/FrostParser.kt
@@ -1,6 +1,14 @@
package com.pitchedapps.frost.parsers
+import com.pitchedapps.frost.dbflow.CookieModel
+import com.pitchedapps.frost.facebook.FB_CSS_URL_MATCHER
+import com.pitchedapps.frost.facebook.formattedFbUrl
+import com.pitchedapps.frost.facebook.get
+import com.pitchedapps.frost.services.NotificationContent
+import com.pitchedapps.frost.utils.frostJsoup
+import org.jsoup.Jsoup
import org.jsoup.nodes.Document
+import org.jsoup.nodes.Element
/**
* Created by Allan Wang on 2017-10-06.
@@ -13,80 +21,88 @@ import org.jsoup.nodes.Document
* 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<T> {
+interface FrostParser<out T : Any> {
+
/**
- * Extracts data from the JSoup document
- * In some cases, the document can be created directly from a connection
- * In other times, it needs to be created from scripts, which otherwise
- * won't be parsed
+ * Url to request from
*/
- fun parse(doc: Document): T?
+ val url: String
/**
- * Parse a String input
+ * Call parsing with default implementation using cookie
*/
- fun parse(text: String?): T?
+ fun parse(cookie: String?): ParseResponse<T>?
/**
- * Take in doc and emit debug output
+ * Call parsing with given document
*/
- fun debug(doc: Document): String
+ fun parse(cookie: String?, document: Document): ParseResponse<T>?
/**
- * Attempts to parse input and emit a debugger
+ * Call parsing with given data
*/
- fun debug(text: String?): String
+ fun parseFromData(cookie: String?, text: String): ParseResponse<T>?
+
+}
+
+data class FrostLink(val text: String, val href: String)
+
+data class ParseResponse<out T>(val cookie: String, val data: T) {
+ override fun toString() = "ParseResponse\ncookie: $cookie\ndata:\n$data"
}
-internal abstract class FrostParserBase<T> : FrostParser<T> {
+interface ParseNotification {
+ fun getUnreadNotifications(data: CookieModel): 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()
+
+/**
+ * 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 : Any>(private val redirectToText: Boolean) : FrostParser<T> {
+
+ override final fun parse(cookie: String?) = parse(cookie, frostJsoup(cookie, url))
- override final fun parse(text: String?): T? {
- text ?: return null
+ override final fun parseFromData(cookie: String?, text: String): ParseResponse<T>? {
+ cookie ?: return null
val doc = textToDoc(text) ?: return null
- return parse(doc)
+ val data = parseImpl(doc) ?: return null
+ return ParseResponse(cookie, data)
}
- protected abstract fun textToDoc(text: String): Document?
-
- override fun debug(text: String?): String {
- val result = mutableListOf<String>()
- result.add("Testing parser for ${this::class.java.simpleName}")
- if (text == null) {
- result.add("Null text input")
- return result.joinToString("\n")
- }
- val doc = textToDoc(text)
- if (doc == null) {
- result.add("Null document from text")
- return result.joinToString("\n")
- }
- return debug(doc, result)
+ 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 final fun debug(doc: Document): String {
- val result = mutableListOf<String>()
- result.add("Testing parser for ${this::class.java.simpleName}")
- return debug(doc, result)
- }
+ protected abstract fun parseImpl(doc: Document): T?
- private fun debug(doc: Document, result: MutableList<String>): String {
- val output = parse(doc)
- if (output == null) {
- result.add("Output is null")
- return result.joinToString("\n")
- } else {
- result.add("Output is not null")
- }
- debugImpl(output, result)
- return result.joinToString("\n")
- }
+ // protected abstract fun parse(doc: Document): T?
- protected abstract fun debugImpl(data: T, result: MutableList<String>)
-}
+ /**
+ * 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() =
+ FB_CSS_URL_MATCHER.find(select("i.img[style*=url]").attr("style"))[1]?.formattedFbUrl ?: ""
-object FrostRegex {
- val epoch = Regex(":([0-9]+)")
- val notifId = Regex("notif_id\":([0-9]+)")
- val messageNotifId = Regex("thread_fbid_([0-9]+)")
- val profilePicture = Regex("url\\(\"(.*?)\"\\)")
+ protected open fun textToDoc(text: String) = 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"))
+ }
} \ No newline at end of file
diff --git a/app/src/main/kotlin/com/pitchedapps/frost/parsers/MessageParser.kt b/app/src/main/kotlin/com/pitchedapps/frost/parsers/MessageParser.kt
index 9430407d..9d4a2193 100644
--- a/app/src/main/kotlin/com/pitchedapps/frost/parsers/MessageParser.kt
+++ b/app/src/main/kotlin/com/pitchedapps/frost/parsers/MessageParser.kt
@@ -1,6 +1,8 @@
package com.pitchedapps.frost.parsers
-import com.pitchedapps.frost.facebook.formattedFbUrl
+import com.pitchedapps.frost.dbflow.CookieModel
+import com.pitchedapps.frost.facebook.*
+import com.pitchedapps.frost.services.NotificationContent
import com.pitchedapps.frost.utils.L
import org.apache.commons.text.StringEscapeUtils
import org.jsoup.Jsoup
@@ -14,13 +16,55 @@ import org.jsoup.nodes.Element
* We can parse out the content we want directly and load it ourselves
*
*/
-object MessageParser : FrostParser<Triple<List<FrostThread>, FrostLink?, List<FrostLink>>> by MessageParserImpl()
+object MessageParser : FrostParser<FrostMessages> by MessageParserImpl()
-data class FrostThread(val id: Int, val img: String, val title: String, val time: Long, val url: String, val unread: Boolean, val content: String?)
+data class FrostMessages(val threads: List<FrostThread>,
+ val seeMore: FrostLink?,
+ val extraLinks: List<FrostLink>
+) : ParseNotification {
+ 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()
-data class FrostLink(val text: String, val href: String)
+ override fun getUnreadNotifications(data: CookieModel) =
+ threads.filter(FrostThread::unread).map {
+ with(it) {
+ NotificationContent(
+ data = data,
+ notifId = Math.abs(id.toInt()),
+ href = url,
+ title = title,
+ text = content ?: "",
+ timestamp = time,
+ profileUrl = img
+ )
+ }
+ }
+}
+
+/**
+ * [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?)
-private class MessageParserImpl : FrostParserBase<Triple<List<FrostThread>, FrostLink?, List<FrostLink>>>() {
+private class MessageParserImpl : FrostParserBase<FrostMessages>(true) {
+
+ override val url = FbItem.MESSAGES.url
override fun textToDoc(text: String): Document? {
var content = StringEscapeUtils.unescapeEcmaScript(text)
@@ -39,32 +83,29 @@ private class MessageParserImpl : FrostParserBase<Triple<List<FrostThread>, Fros
return Jsoup.parseBodyFragment("<div $content")
}
- override fun parse(doc: Document): Triple<List<FrostThread>, FrostLink?, List<FrostLink>>? {
- val threadList = doc.getElementById("threadlist_rows")
+ override fun parseImpl(doc: Document): FrostMessages? {
+ val threadList = doc.getElementById("threadlist_rows") ?: return null
val threads: List<FrostThread> = threadList.getElementsByAttributeValueContaining("id", "thread_fbid_")
- .mapNotNull { parseMessage(it) }
+ .mapNotNull(this::parseMessage)
val seeMore = parseLink(doc.getElementById("see_older_threads"))
val extraLinks = threadList.nextElementSibling().select("a")
- .mapNotNull { parseLink(it) }
- return Triple(threads, seeMore, extraLinks)
+ .mapNotNull(this::parseLink)
+ 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 = FrostRegex.epoch.find(abbr.attr("data-store"))
- ?.groupValues?.getOrNull(1)?.toLongOrNull() ?: -1L
+ val epoch = FB_EPOCH_MATCHER.find(abbr.attr("data-store"))[1]?.toLongOrNull() ?: -1L
//fetch id
- val id = FrostRegex.messageNotifId.find(element.id())
- ?.groupValues?.getOrNull(1)?.toLongOrNull() ?: System.currentTimeMillis()
+ val id = FB_MESSAGE_NOTIF_ID_MATCHER.find(element.id())[1]?.toLongOrNull()
+ ?: System.currentTimeMillis()
val content = element.select("span.snippet").firstOrNull()?.text()?.trim()
- //fetch convo pic
- val p = element.select("i.img[style*=url]")
- val pUrl = FrostRegex.profilePicture.find(p.attr("style"))?.groups?.get(1)?.value?.formattedFbUrl ?: ""
+ val img = element.getInnerImgStyle()
L.v("url", a.attr("href"))
return FrostThread(
- id = id.toInt(),
- img = pUrl.formattedFbUrl,
+ id = id,
+ img = img,
title = a.text(),
time = epoch,
url = a.attr("href").formattedFbUrl,
@@ -73,15 +114,4 @@ private class MessageParserImpl : FrostParserBase<Triple<List<FrostThread>, Fros
)
}
- private fun parseLink(element: Element?): FrostLink? {
- val a = element?.getElementsByTag("a")?.first() ?: return null
- return FrostLink(a.text(), a.attr("href"))
- }
-
- override fun debugImpl(data: Triple<List<FrostThread>, FrostLink?, List<FrostLink>>, result: MutableList<String>) {
- result.addAll(data.first.map(FrostThread::toString))
- result.add("See more link:")
- result.add("\t${data.second}")
- result.addAll(data.third.map(FrostLink::toString))
- }
}
diff --git a/app/src/main/kotlin/com/pitchedapps/frost/parsers/NotifParser.kt b/app/src/main/kotlin/com/pitchedapps/frost/parsers/NotifParser.kt
new file mode 100644
index 00000000..f743a43a
--- /dev/null
+++ b/app/src/main/kotlin/com/pitchedapps/frost/parsers/NotifParser.kt
@@ -0,0 +1,92 @@
+package com.pitchedapps.frost.parsers
+
+import com.pitchedapps.frost.dbflow.CookieModel
+import com.pitchedapps.frost.facebook.*
+import com.pitchedapps.frost.services.NotificationContent
+import com.pitchedapps.frost.utils.L
+import org.jsoup.nodes.Document
+import org.jsoup.nodes.Element
+
+/**
+ * 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 {
+ override fun toString() = StringBuilder().apply {
+ append("FrostNotifs {\n")
+ append(notifs.toJsonString("notifs", 1))
+ append("\tsee more: $seeMore\n")
+ append("}")
+ }.toString()
+
+ override fun getUnreadNotifications(data: CookieModel) =
+ notifs.filter(FrostNotif::unread).map {
+ with(it) {
+ NotificationContent(
+ data = data,
+ notifId = Math.abs(id.toInt()),
+ href = url,
+ title = null,
+ text = content ?: "",
+ timestamp = time,
+ profileUrl = img
+ )
+ }
+ }
+}
+
+/**
+ * [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
+ */
+data class FrostNotif(val id: Long,
+ val img: String,
+ val time: Long,
+ val url: String,
+ val unread: Boolean,
+ val content: String?)
+
+private class NotifParserImpl : FrostParserBase<FrostNotifs>(false) {
+
+ override val url = FbItem.NOTIFICATIONS.url
+
+ override fun parseImpl(doc: Document): FrostNotifs? {
+ val notificationList = doc.getElementById("notifications_list") ?: return null
+ val notifications = notificationList.getElementsByAttributeValueContaining("id", "list_notif_")
+ .mapNotNull { parseNotif(it) }
+ 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
+ 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()
+ val img = element.getInnerImgStyle()
+ val timeString = abbr.text()
+ val content = a.text().replace("\u00a0", " ").removeSuffix(timeString).trim() //remove &nbsp;
+ L.v("url", a.attr("href"))
+ return FrostNotif(
+ id = id,
+ img = img,
+ time = epoch,
+ url = a.attr("href").formattedFbUrl,
+ unread = !element.hasClass("acw"),
+ content = content
+ )
+ }
+
+
+}
diff --git a/app/src/main/kotlin/com/pitchedapps/frost/parsers/SearchParser.kt b/app/src/main/kotlin/com/pitchedapps/frost/parsers/SearchParser.kt
index 908bb153..bc09d4db 100644
--- a/app/src/main/kotlin/com/pitchedapps/frost/parsers/SearchParser.kt
+++ b/app/src/main/kotlin/com/pitchedapps/frost/parsers/SearchParser.kt
@@ -1,8 +1,10 @@
package com.pitchedapps.frost.parsers
-import ca.allanwang.kau.utils.withMaxLength
+import ca.allanwang.kau.searchview.SearchItem
+import com.pitchedapps.frost.dbflow.CookieModel
import com.pitchedapps.frost.facebook.FbItem
import com.pitchedapps.frost.facebook.formattedFbUrl
+import com.pitchedapps.frost.parsers.FrostSearch.Companion.create
import com.pitchedapps.frost.utils.L
import com.pitchedapps.frost.utils.frostJsoup
import org.jsoup.Jsoup
@@ -12,11 +14,11 @@ import org.jsoup.nodes.Element
/**
* Created by Allan Wang on 2017-10-09.
*/
-object SearchParser : FrostParser<List<FrostSearch>> by SearchParserImpl() {
- fun query(input: String): List<FrostSearch>? {
+object SearchParser : FrostParser<FrostSearches> by SearchParserImpl() {
+ fun query(cookie: String?, input: String): ParseResponse<FrostSearches>? {
val url = "${FbItem._SEARCH.url}?q=${if (input.isNotBlank()) input else "a"}"
L.i(null, "Search Query $url")
- return parse(frostJsoup(url))
+ return parse(cookie, frostJsoup(url))
}
}
@@ -25,25 +27,40 @@ enum class SearchKeys(val key: String) {
EVENTS("keywords_events")
}
+data class FrostSearches(val results: List<FrostSearch>) {
+
+ override fun toString() = StringBuilder().apply {
+ append("FrostSearches {\n")
+ append(results.toJsonString("results", 1))
+ append("}")
+ }.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
+ *
+ * Note that it's best to create search results from [create]
*/
-class FrostSearch(href: String, title: String, description: String?) {
- val href = with(href.indexOf("?")) { if (this == -1) href else href.substring(0, this) }
- val title = title.format()
- val description = description?.format()
+data class FrostSearch(val href: String, val title: String, val description: String?) {
- private fun String.format() = replace("\n", " ").withMaxLength(50)
-
- override fun toString(): String
- = "FrostSearch(href=$href, title=$title, description=$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()
+ )
+ }
}
-private class SearchParserImpl : FrostParserBase<List<FrostSearch>>() {
- override fun parse(doc: Document): List<FrostSearch>? {
+private class SearchParserImpl : FrostParserBase<FrostSearches>(false) {
+
+ override val url = "${FbItem._SEARCH.url}?q=a"
+
+ override fun parseImpl(doc: Document): FrostSearches? {
val container: Element = doc.getElementById("BrowseResultsContainer")
?: doc.getElementById("root")
?: return null
@@ -51,19 +68,11 @@ private class SearchParserImpl : FrostParserBase<List<FrostSearch>>() {
*
* Removed [data-store*=result_id]
*/
- return container.select("a.touchable[href]").filter(Element::hasText).map {
- FrostSearch(it.attr("href").formattedFbUrl,
+ return FrostSearches(container.select("a.touchable[href]").filter(Element::hasText).map {
+ FrostSearch.create(it.attr("href").formattedFbUrl,
it.select("._uoi").first()?.text() ?: "",
it.select("._1tcc").first()?.text())
- }.filter { it.title.isNotBlank() }
- }
-
-
- override fun textToDoc(text: String): Document? = Jsoup.parse(text)
-
- override fun debugImpl(data: List<FrostSearch>, result: MutableList<String>) {
- result.add("Has size ${data.size}")
- result.addAll(data.map(FrostSearch::toString))
+ }.filter { it.title.isNotBlank() })
}
} \ No newline at end of file
diff --git a/app/src/main/kotlin/com/pitchedapps/frost/services/FrostNotifications.kt b/app/src/main/kotlin/com/pitchedapps/frost/services/FrostNotifications.kt
index 44b01bc3..afa30a91 100644
--- a/app/src/main/kotlin/com/pitchedapps/frost/services/FrostNotifications.kt
+++ b/app/src/main/kotlin/com/pitchedapps/frost/services/FrostNotifications.kt
@@ -24,12 +24,17 @@ import com.pitchedapps.frost.BuildConfig
import com.pitchedapps.frost.R
import com.pitchedapps.frost.activities.FrostWebActivity
import com.pitchedapps.frost.dbflow.CookieModel
+import com.pitchedapps.frost.dbflow.NotificationModel
+import com.pitchedapps.frost.dbflow.lastNotificationTime
import com.pitchedapps.frost.enums.OverlayContext
import com.pitchedapps.frost.facebook.FbItem
-import com.pitchedapps.frost.facebook.formattedFbUrl
-import com.pitchedapps.frost.parsers.FrostThread
+import com.pitchedapps.frost.parsers.FrostParser
+import com.pitchedapps.frost.parsers.MessageParser
+import com.pitchedapps.frost.parsers.NotifParser
+import com.pitchedapps.frost.parsers.ParseNotification
import com.pitchedapps.frost.utils.*
import org.jetbrains.anko.runOnUiThread
+import java.util.*
/**
* Created by Allan Wang on 2017-07-08.
@@ -88,23 +93,66 @@ class FrostNotificationTarget(val context: Context,
* Enum to handle notification creations
*/
enum class NotificationType(
- private val groupPrefix: String,
private val overlayContext: OverlayContext,
- private val contentRes: Int,
- private val pendingUrl: String,
+ private val fbItem: FbItem,
+ private val parser: FrostParser<ParseNotification>,
+ private val getTime: (notif: NotificationModel) -> Long,
+ private val putTime: (notif: NotificationModel, time: Long) -> NotificationModel,
private val ringtone: () -> String) {
- GENERAL("frost", OverlayContext.NOTIFICATION, R.string.notifications, FbItem.NOTIFICATIONS.url, { Prefs.notificationRingtone }),
- MESSAGE("frost_im", OverlayContext.MESSAGE, R.string.messages, FbItem.MESSAGES.url, { Prefs.messageRingtone });
+ GENERAL(OverlayContext.NOTIFICATION,
+ FbItem.NOTIFICATIONS,
+ NotifParser,
+ NotificationModel::epoch,
+ { notif, time -> notif.copy(epoch = time) },
+ Prefs::notificationRingtone),
+ MESSAGE(OverlayContext.MESSAGE,
+ FbItem.MESSAGES,
+ MessageParser,
+ NotificationModel::epochIm,
+ { notif, time -> notif.copy(epochIm = time) },
+ Prefs::messageRingtone);
+
+ private val groupPrefix = "frost_${name.toLowerCase(Locale.CANADA)}"
+
+ /**
+ * Get unread data from designated parser
+ * Display notifications for those after old epoch
+ * Save new epoch
+ */
+ fun fetch(context: Context, data: CookieModel) {
+ val response = parser.parse(data.cookie)
+ ?: return L.eThrow("$name notification data not found")
+ val notifs = response.data.getUnreadNotifications(data)
+ if (notifs.isEmpty()) return
+ var notifCount = 0
+ val userId = data.id
+ val prevNotifTime = lastNotificationTime(userId)
+ val prevLatestEpoch = getTime(prevNotifTime)
+ L.v("Notif $name prev epoch $prevLatestEpoch")
+ var newLatestEpoch = prevLatestEpoch
+ notifs.forEach { notif ->
+ L.v("Notif timestamp ${notif.timestamp}")
+ if (notif.timestamp <= prevLatestEpoch) return@forEach
+ createNotification(context, notif, notifCount == 0)
+ if (notif.timestamp > newLatestEpoch)
+ newLatestEpoch = notif.timestamp
+ notifCount++
+ }
+ if (newLatestEpoch != prevLatestEpoch)
+ putTime(prevNotifTime, newLatestEpoch).save()
+ L.d("Notif $name new epoch ${getTime(lastNotificationTime(userId))}")
+ summaryNotification(context, userId, notifCount)
+ }
/**
* Create and submit a new notification with the given [content]
* If [withDefaults] is set, it will also add the appropriate sound, vibration, and light
* Note that when we have multiple notifications coming in at once, we don't want to have defaults for all of them
*/
- fun createNotification(context: Context, content: NotificationContent, withDefaults: Boolean) {
+ private fun createNotification(context: Context, content: NotificationContent, withDefaults: Boolean) {
with(content) {
val intent = Intent(context, FrostWebActivity::class.java)
- intent.data = Uri.parse(href.formattedFbUrl)
+ intent.data = Uri.parse(href)
intent.putExtra(ARG_USER_ID, data.id)
intent.putExtra(ARG_OVERLAY_CONTEXT, overlayContext)
val group = "${groupPrefix}_${data.id}"
@@ -142,16 +190,16 @@ enum class NotificationType(
* This will always produce sound, vibration, and lights based on preferences
* and will only show if we have at least 2 notifications
*/
- fun summaryNotification(context: Context, userId: Long, count: Int) {
+ private fun summaryNotification(context: Context, userId: Long, count: Int) {
frostAnswersCustom("Notifications", "Type" to name, "Count" to count)
if (count <= 1) return
val intent = Intent(context, FrostWebActivity::class.java)
- intent.data = Uri.parse(pendingUrl)
+ intent.data = Uri.parse(fbItem.url)
intent.putExtra(ARG_USER_ID, userId)
val pendingIntent = PendingIntent.getActivity(context, 0, intent, 0)
val notifBuilder = context.frostNotification.withDefaults(ringtone())
.setContentTitle(context.string(R.string.frost_name))
- .setContentText("$count ${context.string(contentRes)}")
+ .setContentText("$count ${context.string(fbItem.titleId)}")
.setGroup("${groupPrefix}_$userId")
.setGroupSummary(true)
.setContentIntent(pendingIntent)
@@ -167,13 +215,10 @@ enum class NotificationType(
data class NotificationContent(val data: CookieModel,
val notifId: Int,
val href: String,
- val title: String? = null,
+ val title: String? = null, // defaults to frost title
val text: String,
val timestamp: Long,
- val profileUrl: String) {
- constructor(data: CookieModel, thread: FrostThread)
- : this(data, thread.id, thread.url, thread.title, thread.content ?: "", thread.time, thread.img)
-}
+ val profileUrl: String)
const val NOTIFICATION_PERIODIC_JOB = 7
diff --git a/app/src/main/kotlin/com/pitchedapps/frost/services/NotificationService.kt b/app/src/main/kotlin/com/pitchedapps/frost/services/NotificationService.kt
index c4ab6161..adeefec6 100644
--- a/app/src/main/kotlin/com/pitchedapps/frost/services/NotificationService.kt
+++ b/app/src/main/kotlin/com/pitchedapps/frost/services/NotificationService.kt
@@ -7,18 +7,11 @@ import android.support.v4.app.NotificationManagerCompat
import ca.allanwang.kau.utils.string
import com.pitchedapps.frost.BuildConfig
import com.pitchedapps.frost.R
-import com.pitchedapps.frost.dbflow.CookieModel
-import com.pitchedapps.frost.dbflow.lastNotificationTime
import com.pitchedapps.frost.dbflow.loadFbCookiesSync
-import com.pitchedapps.frost.facebook.FbItem
-import com.pitchedapps.frost.facebook.formattedFbUrl
-import com.pitchedapps.frost.parsers.MessageParser
import com.pitchedapps.frost.utils.L
import com.pitchedapps.frost.utils.Prefs
import com.pitchedapps.frost.utils.frostAnswersCustom
-import com.pitchedapps.frost.utils.frostJsoup
import org.jetbrains.anko.doAsync
-import org.jsoup.nodes.Element
import java.util.concurrent.Future
/**
@@ -27,8 +20,7 @@ import java.util.concurrent.Future
* Service to manage notifications
* Will periodically check through all accounts in the db and send notifications when appropriate
*
- * Note that general notifications are parsed directly with Jsoup,
- * but instant messages are done so with a headless webview as it is generated from JS
+ * All fetching is done through parsers
*/
class NotificationService : JobService() {
@@ -36,13 +28,6 @@ class NotificationService : JobService() {
val startTime = System.currentTimeMillis()
- companion object {
- val epochMatcher: Regex by lazy { Regex(":([0-9]*?),") }
- val notifIdMatcher: Regex by lazy { Regex("notif_id\":([0-9]*?),") }
- val messageNotifIdMatcher: Regex by lazy { Regex("thread_fbid_([0-9]+)") }
- val profMatcher: Regex by lazy { Regex("url\\(\"(.*?)\"\\)") }
- }
-
override fun onStopJob(params: JobParameters?): Boolean {
val time = System.currentTimeMillis() - startTime
L.d("Notification service has finished abruptly in $time ms")
@@ -70,104 +55,28 @@ class NotificationService : JobService() {
override fun onStartJob(params: JobParameters?): Boolean {
L.i("Fetching notifications")
future = doAsync {
+ val context = weakRef.get()
+ ?: return@doAsync L.eThrow("NotificationService had null weakRef to self")
val currentId = Prefs.userId
val cookies = loadFbCookiesSync()
cookies.forEach {
val current = it.id == currentId
if (current || Prefs.notificationAllAccounts)
- fetchGeneralNotifications(it)
- if (Prefs.notificationsInstantMessages && (current || Prefs.notificationsImAllAccounts))
- fetchMessageNotifications(it)
+ NotificationType.GENERAL.fetch(context, it)
+ if (Prefs.notificationsInstantMessages
+ && (current || Prefs.notificationsImAllAccounts))
+ NotificationType.MESSAGE.fetch(context, it)
}
finish(params)
}
return true
}
- fun logNotif(text: String): NotificationContent? {
+ private fun logNotif(text: String): NotificationContent? {
L.eThrow("NotificationService: $text")
return null
}
- /*
- * ----------------------------------------------------------------
- * General notification logic.
- * Fetch notifications -> Filter new ones -> Parse notifications ->
- * Show notifications -> Show group notification
- * ----------------------------------------------------------------
- */
-
- fun fetchGeneralNotifications(data: CookieModel) {
- L.d("Notif fetch", data.toString())
- val doc = frostJsoup(data.cookie, FbItem.NOTIFICATIONS.url)
- //aclb for unread, acw for read
- val unreadNotifications = (doc.getElementById("notifications_list") ?: return L.eThrow("Notification list not found")).getElementsByClass("aclb")
- var notifCount = 0
- //val prevLatestEpoch = 1498931565L // for testing
- val prevNotifTime = lastNotificationTime(data.id)
- val prevLatestEpoch = prevNotifTime.epoch
- L.v("Notif Prev Latest Epoch $prevLatestEpoch")
- var newLatestEpoch = prevLatestEpoch
- unreadNotifications.forEach unread@ { elem ->
- val notif = parseNotification(data, elem) ?: return@unread
- L.v("Notif timestamp ${notif.timestamp}")
- if (notif.timestamp <= prevLatestEpoch) return@unread
- NotificationType.GENERAL.createNotification(this, notif, notifCount == 0)
- if (notif.timestamp > newLatestEpoch)
- newLatestEpoch = notif.timestamp
- notifCount++
- }
- if (newLatestEpoch != prevLatestEpoch) prevNotifTime.copy(epoch = newLatestEpoch).save()
- L.d("Notif new latest epoch ${lastNotificationTime(data.id).epoch}")
- NotificationType.GENERAL.summaryNotification(this, data.id, notifCount)
- }
-
- fun parseNotification(data: CookieModel, element: Element): NotificationContent? {
- val a = element.getElementsByTag("a").first() ?: return logNotif("IM No a tag")
- val abbr = element.getElementsByTag("abbr")
- val epoch = epochMatcher.find(abbr.attr("data-store"))?.groups?.get(1)?.value?.toLong() ?: return logNotif("IM No epoch")
- //fetch id
- val notifId = notifIdMatcher.find(a.attr("data-store"))?.groups?.get(1)?.value?.toLong() ?: System.currentTimeMillis()
- val timeString = abbr.text()
- val text = a.text().replace("\u00a0", " ").removeSuffix(timeString).trim() //remove &nbsp;
- if (Prefs.notificationKeywords.any { text.contains(it, ignoreCase = true) }) return null //notification filtered out
- //fetch profpic
- val p = element.select("i.img[style*=url]")
- val pUrl = profMatcher.find(p.attr("style"))?.groups?.get(1)?.value?.formattedFbUrl ?: ""
- return NotificationContent(data, notifId.toInt(), a.attr("href"), null, text, epoch, pUrl)
- }
-
- /*
- * ----------------------------------------------------------------
- * Instant message notification logic.
- * Fetch notifications -> Filter new ones -> Parse notifications ->
- * Show notifications -> Show group notification
- * ----------------------------------------------------------------
- */
-
- fun fetchMessageNotifications(data: CookieModel) {
- L.d("Notif IM fetch", data.toString())
- val doc = frostJsoup(data.cookie, FbItem.MESSAGES.url)
- val (threads, _, _) = MessageParser.parse(doc.toString()) ?: return L.e("Could not parse IM")
-
- var notifCount = 0
- val prevNotifTime = lastNotificationTime(data.id)
- val prevLatestEpoch = prevNotifTime.epochIm
- L.v("Notif Prev Latest Im Epoch $prevLatestEpoch")
- var newLatestEpoch = prevLatestEpoch
- threads.filter { it.unread }.forEach { notif ->
- L.v("Notif Im timestamp ${notif.time}")
- if (notif.time <= prevLatestEpoch) return@forEach
- NotificationType.MESSAGE.createNotification(this, NotificationContent(data, notif), notifCount == 0)
- if (notif.time > newLatestEpoch)
- newLatestEpoch = notif.time
- notifCount++
- }
- if (newLatestEpoch != prevLatestEpoch) prevNotifTime.copy(epochIm = newLatestEpoch).save()
- L.d("Notif new latest im epoch ${lastNotificationTime(data.id).epochIm}")
- NotificationType.MESSAGE.summaryNotification(this, data.id, notifCount)
- }
-
private fun Context.debugNotification(text: String) {
if (!BuildConfig.DEBUG) return
val notifBuilder = frostNotification.withDefaults()
diff --git a/app/src/main/kotlin/com/pitchedapps/frost/settings/Notifications.kt b/app/src/main/kotlin/com/pitchedapps/frost/settings/Notifications.kt
index 4bd41802..1108f5d4 100644
--- a/app/src/main/kotlin/com/pitchedapps/frost/settings/Notifications.kt
+++ b/app/src/main/kotlin/com/pitchedapps/frost/settings/Notifications.kt
@@ -22,7 +22,7 @@ import com.pitchedapps.frost.views.Keywords
*/
fun SettingsActivity.getNotificationPrefs(): KPrefAdapterBuilder.() -> Unit = {
- text(R.string.notification_frequency, { Prefs.notificationFreq }, { Prefs.notificationFreq = it }) {
+ text(R.string.notification_frequency, Prefs::notificationFreq, { Prefs.notificationFreq = it }) {
val options = longArrayOf(-1, 15, 30, 60, 120, 180, 300, 1440, 2880)
val texts = options.map { if (it <= 0) string(R.string.no_notifications) else minuteToText(it) }
onClick = {
@@ -52,23 +52,27 @@ fun SettingsActivity.getNotificationPrefs(): KPrefAdapterBuilder.() -> Unit = {
}
}
- checkbox(R.string.notification_all_accounts, { Prefs.notificationAllAccounts }, { Prefs.notificationAllAccounts = it }) {
+ checkbox(R.string.notification_all_accounts, Prefs::notificationAllAccounts, { Prefs.notificationAllAccounts = it }) {
descRes = R.string.notification_all_accounts_desc
}
- checkbox(R.string.notification_messages, { Prefs.notificationsInstantMessages }, { Prefs.notificationsInstantMessages = it; reloadByTitle(R.string.notification_messages_all_accounts) }) {
+ checkbox(R.string.notification_messages, Prefs::notificationsInstantMessages, { Prefs.notificationsInstantMessages = it; reloadByTitle(R.string.notification_messages_all_accounts) }) {
descRes = R.string.notification_messages_desc
}
- checkbox(R.string.notification_messages_all_accounts, { Prefs.notificationsImAllAccounts }, { Prefs.notificationsImAllAccounts = it }) {
+ checkbox(R.string.notification_messages_all_accounts, Prefs::notificationsImAllAccounts, { Prefs.notificationsImAllAccounts = it }) {
descRes = R.string.notification_messages_all_accounts_desc
- enabler = { Prefs.notificationsInstantMessages }
+ enabler = Prefs::notificationsInstantMessages
}
- checkbox(R.string.notification_sound, { Prefs.notificationSound }, { Prefs.notificationSound = it; reloadByTitle(R.string.notification_ringtone, R.string.message_ringtone) })
+ checkbox(R.string.notification_sound, Prefs::notificationSound, {
+ Prefs.notificationSound = it
+ reloadByTitle(R.string.notification_ringtone,
+ R.string.message_ringtone)
+ })
fun KPrefText.KPrefTextContract<String>.ringtone(code: Int) {
- enabler = { Prefs.notificationSound }
+ enabler = Prefs::notificationSound
textGetter = {
if (it.isBlank()) string(R.string.kau_default)
else RingtoneManager.getRingtone(this@getNotificationPrefs, Uri.parse(it))
@@ -87,17 +91,17 @@ fun SettingsActivity.getNotificationPrefs(): KPrefAdapterBuilder.() -> Unit = {
}
}
- text(R.string.notification_ringtone, { Prefs.notificationRingtone }, { Prefs.notificationRingtone = it }) {
+ text(R.string.notification_ringtone, Prefs::notificationRingtone, { Prefs.notificationRingtone = it }) {
ringtone(SettingsActivity.REQUEST_NOTIFICATION_RINGTONE)
}
- text(R.string.message_ringtone, { Prefs.messageRingtone }, { Prefs.messageRingtone = it }) {
+ text(R.string.message_ringtone, Prefs::messageRingtone, { Prefs.messageRingtone = it }) {
ringtone(SettingsActivity.REQUEST_MESSAGE_RINGTONE)
}
- checkbox(R.string.notification_vibrate, { Prefs.notificationVibrate }, { Prefs.notificationVibrate = it })
+ checkbox(R.string.notification_vibrate, Prefs::notificationVibrate, { Prefs.notificationVibrate = it })
- checkbox(R.string.notification_lights, { Prefs.notificationLights }, { Prefs.notificationLights = it })
+ checkbox(R.string.notification_lights, Prefs::notificationLights, { Prefs.notificationLights = it })
plainText(R.string.notification_fetch_now) {
descRes = R.string.notification_fetch_now_desc
diff --git a/app/src/main/kotlin/com/pitchedapps/frost/web/LoginWebView.kt b/app/src/main/kotlin/com/pitchedapps/frost/web/LoginWebView.kt
index f8b487a2..9251e607 100644
--- a/app/src/main/kotlin/com/pitchedapps/frost/web/LoginWebView.kt
+++ b/app/src/main/kotlin/com/pitchedapps/frost/web/LoginWebView.kt
@@ -2,6 +2,7 @@ package com.pitchedapps.frost.web
import android.annotation.SuppressLint
import android.content.Context
+import android.graphics.Bitmap
import android.graphics.Color
import android.util.AttributeSet
import android.view.View
@@ -12,6 +13,7 @@ import com.pitchedapps.frost.dbflow.CookieModel
import com.pitchedapps.frost.facebook.FB_LOGIN_URL
import com.pitchedapps.frost.facebook.FB_USER_MATCHER
import com.pitchedapps.frost.facebook.FbCookie
+import com.pitchedapps.frost.facebook.get
import com.pitchedapps.frost.injectors.CssHider
import com.pitchedapps.frost.injectors.jsInject
import com.pitchedapps.frost.utils.L
@@ -31,7 +33,7 @@ class LoginWebView @JvmOverloads constructor(
private lateinit var progressCallback: (Int) -> Unit
init {
- FbCookie.reset { setupWebview() }
+ FbCookie.reset(this::setupWebview)
}
@SuppressLint("SetJavaScriptEnabled")
@@ -62,13 +64,14 @@ class LoginWebView @JvmOverloads constructor(
if (!url.isFacebookUrl) return@doAsync
val cookie = CookieManager.getInstance().getCookie(url) ?: return@doAsync
L.d("Checking cookie for login", cookie)
- val id = FB_USER_MATCHER.find(cookie)?.groupValues?.get(1)?.toLong() ?: return@doAsync
+ val id = FB_USER_MATCHER.find(cookie)[1]?.toLong() ?: return@doAsync
uiThread { onFound(id, cookie) }
}
}
override fun onPageCommitVisible(view: WebView, url: String?) {
super.onPageCommitVisible(view, url)
+ L.d("Login page commit visible")
view.setBackgroundColor(Color.TRANSPARENT)
if (url.isFacebookUrl)
view.jsInject(CssHider.HEADER,
diff --git a/app/src/main/res/xml/frost_changelog.xml b/app/src/main/res/xml/frost_changelog.xml
index b5e91310..01785447 100644
--- a/app/src/main/res/xml/frost_changelog.xml
+++ b/app/src/main/res/xml/frost_changelog.xml
@@ -6,6 +6,13 @@
<item text="" />
-->
+ <version title="v1.7.2" />
+ <item text="Optimize login view" />
+ <item text="Rewrite parsers" />
+ <item text="Fix message notification icons" />
+ <item text="Small theme updates" />
+ <item text="" />
+ <item text="" />
<version title="v1.7.1" />
<item text="Fix launching messages in new overlay" />
@@ -14,8 +21,6 @@
<item text="Automatically bring toolbar up when keyboard is shown" />
<item text="Rewrite theme components to fully support AMOLED and improve light" />
<item text="Properly pause webviews when not in use" />
- <item text="" />
- <item text="" />
<version title="v1.7.0" />
<item text="Fully customize your tabs! Check out settings > appearance > main activity tabs" />
diff --git a/app/src/test/kotlin/com/pitchedapps/frost/facebook/FbParseTest.kt b/app/src/test/kotlin/com/pitchedapps/frost/facebook/FbParseTest.kt
new file mode 100644
index 00000000..65777f97
--- /dev/null
+++ b/app/src/test/kotlin/com/pitchedapps/frost/facebook/FbParseTest.kt
@@ -0,0 +1,46 @@
+package com.pitchedapps.frost.facebook
+
+import com.pitchedapps.frost.internal.COOKIE
+import com.pitchedapps.frost.internal.assertComponentsNotEmpty
+import com.pitchedapps.frost.internal.assertDescending
+import com.pitchedapps.frost.internal.authDependent
+import com.pitchedapps.frost.parsers.*
+import org.junit.BeforeClass
+import org.junit.Test
+import kotlin.test.fail
+
+/**
+ * Created by Allan Wang on 24/12/17.
+ */
+class FbParseTest {
+
+ companion object {
+ @BeforeClass
+ @JvmStatic
+ fun before() {
+ authDependent()
+ }
+ }
+
+ private inline fun <T : Any> FrostParser<T>.test(action: T.() -> Unit = {}) {
+ val response = parse(COOKIE)
+ ?: fail("${this::class.java.simpleName} returned null for $url")
+ println(response)
+ response.data.action()
+ }
+
+ @Test
+ fun message() = MessageParser.test {
+ threads.forEach(FrostThread::assertComponentsNotEmpty)
+ threads.map(FrostThread::time).assertDescending("thread time values")
+ }
+
+ @Test
+ fun search() = SearchParser.test()
+
+ @Test
+ fun notif() = NotifParser.test {
+ notifs.forEach(FrostNotif::assertComponentsNotEmpty)
+ notifs.map(FrostNotif::time).assertDescending("notif time values")
+ }
+} \ No newline at end of file
diff --git a/app/src/test/kotlin/com/pitchedapps/frost/facebook/FbRegexTest.kt b/app/src/test/kotlin/com/pitchedapps/frost/facebook/FbRegexTest.kt
new file mode 100644
index 00000000..a21bcb13
--- /dev/null
+++ b/app/src/test/kotlin/com/pitchedapps/frost/facebook/FbRegexTest.kt
@@ -0,0 +1,41 @@
+package com.pitchedapps.frost.facebook
+
+import org.apache.commons.text.StringEscapeUtils
+import org.junit.Test
+import kotlin.test.assertEquals
+
+/**
+ * Created by Allan Wang on 24/12/17.
+ */
+class FbRegexTest {
+ @Test
+ fun userIdRegex() {
+ val id = 12349876L
+ val cookie = "wd=1366x615; c_user=$id; act=1234%2F12; m_pixel_ratio=1; presence=hello; x-referer=asdfasdf"
+ assertEquals(id, FB_USER_MATCHER.find(cookie)[1]?.toLong())
+ }
+
+ @Test
+ fun fbDtsgRegex() {
+ val fb_dtsg = "readme"
+ val input = "data-sigil=\"mbasic_inline_feed_composer\">\u003Cinput type=\"hidden\" name=\"fb_dtsg\" value=\"$fb_dtsg\" autocomplete=\"off\" \\/>\u003Cinput type=\"hidden\" name=\"privacyx\" value=\"12345\""
+ assertEquals(fb_dtsg, FB_DTSG_MATCHER.find(input)[1])
+ }
+
+ @Test
+ fun ppRegex() {
+ val img = "https\\3a //scontent-yyz1-1.xx.fbcdn.net/v/asdf1234.jpg?efg\\3d 333\\26 oh\\3d 77\\26 oe\\3d 444"
+ val ppStyle = "background:#d8dce6 url('$img') no-repeat center;background-size:100% 100%;-webkit-background-size:100% 100%;width:58px;height:58px;"
+ assertEquals(StringEscapeUtils.unescapeCsv(img), StringEscapeUtils.unescapeCsv(FB_CSS_URL_MATCHER.find(ppStyle)[1]))
+ }
+
+ @Test
+ fun msgNotifIdRegex() {
+ val id = 1273491646093428L
+ val data = "threadlist_row_other_user_fbid_thread_fbid_$id"
+ assertEquals(id, FB_MESSAGE_NOTIF_ID_MATCHER.find(data)[1]?.toLong(), "thread_fbid mismatch")
+ val userData = "threadlist_row_other_user_fbid_${id}thread_fbid_"
+ assertEquals(id, FB_MESSAGE_NOTIF_ID_MATCHER.find(userData)[1]?.toLong(), "user_fbid mismatch")
+
+ }
+} \ No newline at end of file
diff --git a/app/src/test/kotlin/com/pitchedapps/frost/facebook/FbRequestTest.kt b/app/src/test/kotlin/com/pitchedapps/frost/facebook/FbRequestTest.kt
index a521ceda..16894b16 100644
--- a/app/src/test/kotlin/com/pitchedapps/frost/facebook/FbRequestTest.kt
+++ b/app/src/test/kotlin/com/pitchedapps/frost/facebook/FbRequestTest.kt
@@ -1,14 +1,13 @@
package com.pitchedapps.frost.facebook
+import com.pitchedapps.frost.internal.AUTH
import com.pitchedapps.frost.internal.COOKIE
-import com.pitchedapps.frost.internal.FB_DTSG
import com.pitchedapps.frost.internal.USER_ID
-import org.junit.Assume
+import com.pitchedapps.frost.internal.authDependent
+import okhttp3.Call
import org.junit.BeforeClass
import org.junit.Test
-import kotlin.test.assertEquals
-import kotlin.test.assertFalse
-import kotlin.test.assertNotNull
+import kotlin.test.*
/**
* Created by Allan Wang on 21/12/17.
@@ -19,44 +18,34 @@ class FbRequestTest {
@BeforeClass
@JvmStatic
fun before() {
- Assume.assumeTrue(COOKIE.isNotEmpty())
+ authDependent()
}
-
- val AUTH: RequestAuth by lazy { RequestAuth(USER_ID, COOKIE, FB_DTSG) }
}
- @Test
- fun userIdRegex() {
- val id = 12349876L
- val cookie = "wd=1366x615; c_user=$id; act=1234%2F12; m_pixel_ratio=1; presence=hello; x-referer=asdfasdf"
- assertEquals(id, FB_USER_MATCHER.find(cookie)?.groupValues?.get(1)?.toLong())
- }
-
- @Test
- fun fbDtsgRegex() {
- val fb_dtsg = "readme"
- val input = "data-sigil=\"mbasic_inline_feed_composer\">\u003Cinput type=\"hidden\" name=\"fb_dtsg\" value=\"$fb_dtsg\" autocomplete=\"off\" \\/>\u003Cinput type=\"hidden\" name=\"privacyx\" value=\"12345\""
- assertEquals(fb_dtsg, FB_DTSG_MATCHER.find(input)?.groupValues?.get(1))
+ /**
+ * Used to emulate [executeAndCheck]
+ * Must be consistent with that method
+ */
+ private fun Call.assertNoError() {
+ val data = execute().body()?.string() ?: fail("Content was null")
+ println("Call response: $data")
+ assertTrue(data.isNotEmpty(), "Content was empty")
+ assertFalse(data.contains("error"), "Content had error")
}
@Test
fun auth() {
val auth = (USER_ID to COOKIE).getAuth()
assertNotNull(auth)
- assertEquals(USER_ID, auth!!.userId)
+ assertEquals(USER_ID, auth.userId)
assertEquals(COOKIE, auth.cookie)
- println("Test auth: priv $FB_DTSG, test ${auth.fb_dtsg}")
+ println("Test auth: ${auth.fb_dtsg}")
}
@Test
fun markNotification() {
val notifId = 1513544657695779
-
- val out = AUTH.markNotificationRead(notifId)
- .execute().body()?.string() ?: ""
- println(out)
-
- assertFalse(out.contains("error"))
+ AUTH.markNotificationRead(notifId).assertNoError()
}
} \ No newline at end of file
diff --git a/app/src/test/kotlin/com/pitchedapps/frost/internal/Internal.kt b/app/src/test/kotlin/com/pitchedapps/frost/internal/Internal.kt
index 91eb968d..deaed333 100644
--- a/app/src/test/kotlin/com/pitchedapps/frost/internal/Internal.kt
+++ b/app/src/test/kotlin/com/pitchedapps/frost/internal/Internal.kt
@@ -1,9 +1,15 @@
package com.pitchedapps.frost.internal
-import com.pitchedapps.frost.facebook.FB_USER_MATCHER
+import com.pitchedapps.frost.facebook.*
+import com.pitchedapps.frost.utils.frostJsoup
+import org.junit.Assume
import java.io.File
import java.io.FileInputStream
import java.util.*
+import kotlin.reflect.full.starProjectedType
+import kotlin.test.assertEquals
+import kotlin.test.assertTrue
+import kotlin.test.fail
/**
* Created by Allan Wang on 21/12/17.
@@ -24,5 +30,46 @@ val PROPS: Properties by lazy {
}
val COOKIE: String by lazy { PROPS.getProperty("COOKIE") ?: "" }
-val FB_DTSG: String by lazy { PROPS.getProperty("FB_DTSG") ?: "" }
-val USER_ID: Long by lazy { FB_USER_MATCHER.find(COOKIE)?.groupValues?.get(1)?.toLong() ?: -1 }
+val USER_ID: Long by lazy { FB_USER_MATCHER.find(COOKIE)[1]?.toLong() ?: -1 }
+val AUTH: RequestAuth by lazy {
+ (USER_ID to COOKIE).getAuth().apply {
+ println("Auth:\nuser:$userId\nfb_dtsg: $fb_dtsg\nrev: $rev\nvalid: $isValid")
+ }
+}
+
+val VALID_COOKIE: Boolean by lazy {
+ val data = testJsoup(FbItem.SETTINGS.url)
+ data.title() == "Settings"
+}
+
+fun testJsoup(url: String) = frostJsoup(COOKIE, url)
+
+fun authDependent() {
+ println("Auth Dependent")
+ Assume.assumeTrue(COOKIE.isNotEmpty() && VALID_COOKIE)
+ Assume.assumeTrue(AUTH.isValid)
+}
+
+/**
+ * Check that component strings are nonempty and are properly parsed
+ * To be used for data classes
+ */
+fun Any.assertComponentsNotEmpty() {
+ val components = this::class.members.filter { it.name.startsWith("component") }
+ if (components.isEmpty())
+ fail("${this::class.simpleName} has no components")
+ components.forEach {
+ when (it.returnType) {
+ String::class.starProjectedType -> {
+ val result = it.call(this) as String
+ assertTrue(result.isNotEmpty(), "${it.name} returned empty string")
+ if (result.startsWith("https"))
+ assertTrue(result.startsWith("https://"), "${it.name} has poorly formatted output $result")
+ }
+ }
+ }
+}
+
+fun <T : Comparable<T>> List<T>.assertDescending(tag: String) {
+ assertEquals(sortedDescending(), this, "$tag not sorted in descending order")
+} \ No newline at end of file
diff --git a/app/src/test/kotlin/com/pitchedapps/frost/parsers/MessageParserTest.kt b/app/src/test/kotlin/com/pitchedapps/frost/parsers/MessageParserTest.kt
index 61c69c40..ecebed04 100644
--- a/app/src/test/kotlin/com/pitchedapps/frost/parsers/MessageParserTest.kt
+++ b/app/src/test/kotlin/com/pitchedapps/frost/parsers/MessageParserTest.kt
@@ -1,6 +1,8 @@
package com.pitchedapps.frost.parsers
+import com.pitchedapps.frost.facebook.FB_EPOCH_MATCHER
import com.pitchedapps.frost.facebook.formattedFbUrl
+import com.pitchedapps.frost.facebook.get
import org.junit.Test
import kotlin.test.assertEquals
@@ -15,7 +17,7 @@ class MessageParserTest {
@Test
fun parseEpoch() {
val input = "{\"time\":1507301642,\"short\":true,\"forceseconds\":false}"
- assertEquals(1507301642, FrostRegex.epoch.find(input)!!.groupValues[1].toLong())
+ assertEquals(1507301642, FB_EPOCH_MATCHER.find(input)[1]!!.toLong())
}
@Test
diff --git a/app/src/test/kotlin/com/pitchedapps/frost/parsers/ParserTestHelper.kt b/app/src/test/kotlin/com/pitchedapps/frost/parsers/ParserTestHelper.kt
index be5ac624..53495ecb 100644
--- a/app/src/test/kotlin/com/pitchedapps/frost/parsers/ParserTestHelper.kt
+++ b/app/src/test/kotlin/com/pitchedapps/frost/parsers/ParserTestHelper.kt
@@ -18,5 +18,5 @@ fun <T : Any> T.getResource(path: String): String? {
fun <T : Any, P : Any> T.debug(path: String, parser: FrostParser<P>) {
val content = getResource("priv/$path.html") ?: return
- println(parser.debug(content))
+// println(parser.debug(content))
} \ No newline at end of file