1
0
mirror of https://github.com/AllanWang/Frost-for-Facebook.git synced 2024-09-19 23:21:34 +02:00

Merge dev

This commit is contained in:
Allan Wang 2019-05-01 16:05:19 -07:00
commit 58f4f9298b
No known key found for this signature in database
GPG Key ID: C93E3F9C679D7A56
169 changed files with 4200 additions and 2842 deletions

View File

@ -1,32 +1,23 @@
language: android
jdk:
- oraclejdk8
android:
components:
- tools
- platform-tools
- build-tools-28.0.3
- android-28
- extra-android-support
- extra-android-m2repository
- extra-google-m2repository
licenses:
- ".+"
language: java
services:
- docker
git:
depth: 500
before_install:
- openssl aes-256-cbc -K $encrypted_0454d0cf846c_key -iv $encrypted_0454d0cf846c_iv
-in files/frost.tar.enc -out files/frost.tar -d
- tar xvf files/frost.tar
- yes | sdkmanager "platforms;android-28"
- docker build -q -t frost .
- docker volume create -o device=$HOME/.gradle/caches/ -o o=bind gradle_caches
- docker volume create -o device=$HOME/.gradle/wrapper/ -o o=bind gradle_wrapper
install: true
after_success:
- chmod +x ./generate-apk-release.sh; ./generate-apk-release.sh
- ./generate-apk-release.sh
script:
- cd $TRAVIS_BUILD_DIR/
- printf "Starting script\n"
- chmod +x gradlew
- "./gradlew --quiet androidGitVersion"
- "./gradlew lintReleaseTest testReleaseUnitTest assembleReleaseTest"
- cd $TRAVIS_BUILD_DIR
- docker run --name frost_container -v gradle_caches:/root/.gradle/caches/ -v gradle_wrapper:/root/.gradle/wrapper/ frost
- mkdir $HOME/Frost
- docker cp frost_container:/frost/app/build/outputs/apk/releaseTest/. $HOME/Frost
notifications:
email: false
slack:
@ -46,14 +37,12 @@ branches:
- master
- l10n_dev
before_cache:
- rm -f $HOME/.gradle/caches/modules-2/modules-2.lock
- rm -fr $HOME/.gradle/caches/*/plugin-resolution/
- rm -f $HOME/.gradle/caches/modules-2/modules-2.lock
- rm -rf $HOME/.gradle/caches/*/plugin-resolution/
cache:
directories:
- "$HOME/.gradle/caches/"
- "$HOME/.gradle/wrapper/"
- "$HOME/.android/build-cache"
- "$HOME/.m2s"
- $HOME/.gradle/caches/
- $HOME/.gradle/wrapper/
env:
global:
- secure: X3J97ccW+8K0bXPXhX608vPx7Pr/G4ju7quxydqMaYGgClHxoL/WpXOBAyyllde5P28PY4kioaqcI21BEhnAw0QUbmnzVLA1Qd5VS7aMPHpEnInKuOxGZ2d570OZd1f+ozFVt05vzG0VBJlBAkVhz2GWNxQdmIV1sO28MH526JMuYaEREuuywVSZmAeY7AAbW9MeCC2wvHvNmhk2nk6NLRQcsrDHcBsimy9fnnQ9lT/QsvToi1ZJd/MN7YkGDUULR+YmaotBzG546UJ1EiZQX91bFEJfP0oL43Pk7t5snzmHnKjLOr8Mt5QsIUXaiy/uzhUVmuDh1i0GEpZmhqM7nz/T6P7ogaLbbyJeauNmf15nu+e3hSvNiTzKyIwfSSflv8Do3g8/Eo3dKfIi3I8/OKF/uZ76kywh2LRqtZAqxRDiAMDZVwsRgD4aztoWm5AWa3tSoGy1J7i1eoqX6bNqokRbjgheTqcjN13kCdSZi3pZX7UBYm2Vumhn4izhTume19Rh9SqTmRgQ8jM7ynxHh7vVsJPPJG0HbQ623xz+d9mtXGy1fAb0dcUJMXdOhFN3m6AnKuHiF7cmsqje7Euk/TOZyqZmu0xEhTkugMbNKwGrklJiwRr3IoLtPdhLE38u3/auloUqBQ4K/iA9ZdhAreTSHEaI9d3J4N6kqCj3U30=

49
Dockerfile Normal file
View File

@ -0,0 +1,49 @@
FROM openjdk:8
# Android SDK
ENV ANDROID_HOME /opt/android-sdk-linux
# Download Android SDK into $ANDROID_HOME
# You can find URL to the current version at: https://developer.android.com/studio/index.html
# Or https://github.com/Homebrew/homebrew-cask/blob/master/Casks/android-sdk.rb
RUN mkdir -p ${ANDROID_HOME} && \
cd ${ANDROID_HOME} && \
wget -q https://dl.google.com/android/repository/sdk-tools-linux-4333796.zip -O android_tools.zip && \
unzip android_tools.zip && \
rm android_tools.zip
ENV PATH ${PATH}:${ANDROID_HOME}/tools:${ANDROID_HOME}/tools/bin:${ANDROID_HOME}/platform-tools
# Accept Android SDK licenses && install other elements
# For full list; see sdkmanager --list --verbose
RUN yes | sdkmanager --licenses && \
sdkmanager 'platform-tools' && \
sdkmanager 'extras;google;m2repository' && \
sdkmanager 'extras;android;m2repository'
# SDK Specific
RUN sdkmanager 'platforms;android-28' && \
sdkmanager 'build-tools;28.0.3'
# Install Node.js
ENV NODEJS_VERSION=11.12.0 \
PATH=$PATH:/opt/node/bin
WORKDIR "/opt/node"
RUN apt-get update && apt-get install -y curl git ca-certificates --no-install-recommends && \
curl -sL https://nodejs.org/dist/v${NODEJS_VERSION}/node-v${NODEJS_VERSION}-linux-x64.tar.gz | tar xz --strip-components=1 && \
rm -rf /var/lib/apt/lists/* && \
apt-get clean
RUN mkdir -p /frost/
WORKDIR /frost/
COPY . /frost/
CMD ["./docker_build.sh"]

View File

@ -1,7 +1,7 @@
# Frost-for-Facebook
[![Releaes Version](https://img.shields.io/github/release/AllanWang/Frost-for-Facebook.svg)](https://github.com/AllanWang/Frost-for-Facebook/releases)
[![Build Status](https://travis-ci.org/AllanWang/Frost-for-Facebook.svg?branch=dev)](https://travis-ci.org/AllanWang/Frost-for-Facebook)
[![Build Status](https://travis-ci.com/AllanWang/Frost-for-Facebook.svg?branch=dev)](https://travis-ci.com/AllanWang/Frost-for-Facebook)
[![Crowdin](https://d322cqt584bo4o.cloudfront.net/frost-for-facebook/localized.svg)](https://crowdin.com/project/frost-for-facebook)
[![ZenHub](https://img.shields.io/badge/Shipping%20faster%20with-ZenHub-45529A.svg)](https://app.zenhub.com/workspace/o/allanwang/frost-for-facebook/boards)
[![BugSnag](https://img.shields.io/badge/Bug%20tracking%20with-BugSnag-37C2D9.svg)](https://www.bugsnag.com/)

68
_layouts/default.html Normal file
View File

@ -0,0 +1,68 @@
<!--See https://github.com/pages-themes/minimal/blob/master/_layouts/default.html-->
<!DOCTYPE html>
<html lang="{{ site.lang | default: "en-US" }}">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1">
{% seo %}
<link rel="stylesheet" href="{{ "/assets/css/style.css?v=" | append: site.github.build_revision | relative_url }}">
<!--Begin favicon-->
<link rel="apple-touch-icon" sizes="180x180" href="{{ '/favicon/apple-touch-icon.png' | relative_url }}">
<link rel="icon" type="image/png" sizes="32x32" href="{{ '/favicon/favicon-32x32.png' | relative_url }}">
<link rel="icon" type="image/png" sizes="16x16" href="{{ '/favicon/favicon-16x16.png' | relative_url }}">
<link rel="manifest" href="{{ '/favicon/site.webmanifest' | relative_url }}">
<link rel="mask-icon" href="{{ '/favicon/safari-pinned-tab.svg' | relative_url }}" color="#3b5998">
<link rel="shortcut icon" href="{{ '/favicon/favicon.ico' | relative_url }}">
<meta name="msapplication-TileColor" content="#da532c">
<meta name="msapplication-config" content="{{ '/favicon/browserconfig.xml' | relative_url }}">
<meta name="theme-color" content="#ffffff">
<!--End favicon-->
<!--[if lt IE 9]>
<script src="https://cdnjs.cloudflare.com/ajax/libs/html5shiv/3.7.3/html5shiv.min.js"></script>
<![endif]-->
</head>
<body>
<div class="wrapper">
<header>
<h1><a href="{{ "/" | absolute_url }}">{{ site.title | default: site.github.repository_name }}</a></h1>
{% if site.logo %}
<img src="{{site.logo | relative_url}}" alt="Logo" />
{% endif %}
<p>{{ site.description | default: site.github.project_tagline }}</p>
{% if site.github.is_project_page %}
<p class="view"><a href="{{ site.github.repository_url }}">View the Project on GitHub <small>{{ site.github.repository_nwo }}</small></a></p>
{% endif %}
{% if site.github.is_user_page %}
<p class="view"><a href="{{ site.github.owner_url }}">View My GitHub Profile</a></p>
{% endif %}
{% if site.show_downloads %}
<ul class="downloads">
<li><a href="{{ site.github.zip_url }}">Download <strong>ZIP File</strong></a></li>
<li><a href="{{ site.github.tar_url }}">Download <strong>TAR Ball</strong></a></li>
<li><a href="{{ site.github.repository_url }}">View On <strong>GitHub</strong></a></li>
</ul>
{% endif %}
</header>
<section>
{{ content }}
</section>
<footer>
{% if site.github.is_project_page %}
<p>This project is maintained by <a href="{{ site.github.owner_url }}">{{ site.github.owner_name }}</a></p>
{% endif %}
<p><small>Hosted on GitHub Pages &mdash; Theme by <a href="https://github.com/orderedlist">orderedlist</a></small></p>
</footer>
</div>
<script src="{{ "/assets/js/scale.fix.js" | relative_url }}"></script>
</body>
</html>

View File

@ -27,6 +27,11 @@ android {
versionName androidGitVersion.name()
multiDexEnabled true
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
javaCompileOptions {
annotationProcessorOptions {
arguments = ["room.schemaLocation": "$projectDir/src/schemas".toString()]
}
}
}
applicationVariants.all { variant ->
@ -174,6 +179,7 @@ dependencies {
androidTestImplementation kauDependency.espresso
androidTestImplementation kauDependency.testRules
androidTestImplementation kauDependency.testRunner
androidTestImplementation "org.jetbrains.kotlin:kotlin-reflect:${KOTLIN}"
testImplementation kauDependency.kotlinTest
testImplementation "org.jetbrains.kotlin:kotlin-reflect:${KOTLIN}"
@ -202,9 +208,9 @@ dependencies {
implementation "androidx.biometric:biometric:${ANDX_BIOMETRIC}"
// implementation "org.koin:koin-android:${KOIN}"
// testImplementation "org.koin:koin-test:${KOIN}"
// androidTestImplementation "org.koin:koin-test:${KOIN}"
implementation "org.koin:koin-android:${KOIN}"
testImplementation "org.koin:koin-test:${KOIN}"
androidTestImplementation "org.koin:koin-test:${KOIN}"
// androidTestImplementation "io.mockk:mockk:${MOCKK}"
@ -255,6 +261,11 @@ dependencies {
implementation "com.sothree.slidinguppanel:library:${SLIDING_PANEL}"
implementation "androidx.room:room-coroutines:${ROOM}"
implementation "androidx.room:room-runtime:${ROOM}"
kapt "androidx.room:room-compiler:${ROOM}"
testImplementation "androidx.room:room-testing:${ROOM}"
}
// Validates code and generates apk

View File

@ -0,0 +1,48 @@
/*
* Copyright 2019 Allan Wang
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package com.pitchedapps.frost.db
import android.content.Context
import androidx.room.Room
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.runner.RunWith
import kotlin.test.AfterTest
import kotlin.test.BeforeTest
@RunWith(AndroidJUnit4::class)
abstract class BaseDbTest {
protected lateinit var db: FrostDatabase
@BeforeTest
fun before() {
val context = ApplicationProvider.getApplicationContext<Context>()
val privateDb = Room.inMemoryDatabaseBuilder(
context, FrostPrivateDatabase::class.java
).build()
val publicDb = Room.inMemoryDatabaseBuilder(
context, FrostPublicDatabase::class.java
).build()
db = FrostDatabase(privateDb, publicDb)
}
@AfterTest
fun after() {
db.close()
}
}

View File

@ -0,0 +1,48 @@
/*
* Copyright 2019 Allan Wang
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package com.pitchedapps.frost.db
import kotlinx.coroutines.runBlocking
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
import kotlin.test.fail
class CacheDbTest : BaseDbTest() {
private val dao get() = db.cacheDao()
private val cookieDao get() = db.cookieDao()
private fun cookie(id: Long) = CookieEntity(id, "name$id", "cookie$id")
@Test
fun save() {
val cookie = cookie(1L)
val type = "test"
val content = "long test".repeat(10000)
runBlocking {
cookieDao.save(cookie)
dao.save(cookie.id, type, content)
val cache = dao.select(cookie.id, type) ?: fail("Cache not found")
assertEquals(content, cache.contents, "Content mismatch")
assertTrue(
System.currentTimeMillis() - cache.lastUpdated < 500,
"Cache retrieval took over 500ms (${System.currentTimeMillis() - cache.lastUpdated})"
)
}
}
}

View File

@ -0,0 +1,85 @@
/*
* Copyright 2019 Allan Wang
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package com.pitchedapps.frost.db
import kotlinx.coroutines.runBlocking
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
class CookieDbTest : BaseDbTest() {
private val dao get() = db.cookieDao()
@Test
fun basicCookie() {
val cookie = CookieEntity(id = 1234L, name = "testName", cookie = "testCookie")
runBlocking {
dao.save(cookie)
val cookies = dao.selectAll()
assertEquals(listOf(cookie), cookies, "Cookie mismatch")
}
}
@Test
fun deleteCookie() {
val cookie = CookieEntity(id = 1234L, name = "testName", cookie = "testCookie")
runBlocking {
dao.save(cookie)
dao.deleteById(cookie.id + 1)
assertEquals(
listOf(cookie),
dao.selectAll(),
"Cookie list should be the same after inexistent deletion"
)
dao.deleteById(cookie.id)
assertEquals(emptyList(), dao.selectAll(), "Cookie list should be empty after deletion")
}
}
@Test
fun insertReplaceCookie() {
val cookie = CookieEntity(id = 1234L, name = "testName", cookie = "testCookie")
runBlocking {
dao.save(cookie)
assertEquals(listOf(cookie), dao.selectAll(), "Cookie insertion failed")
dao.save(cookie.copy(name = "testName2"))
assertEquals(
listOf(cookie.copy(name = "testName2")),
dao.selectAll(),
"Cookie replacement failed"
)
dao.save(cookie.copy(id = 123L))
assertEquals(
setOf(cookie.copy(id = 123L), cookie.copy(name = "testName2")),
dao.selectAll().toSet(),
"New cookie insertion failed"
)
}
}
@Test
fun selectCookie() {
val cookie = CookieEntity(id = 1234L, name = "testName", cookie = "testCookie")
runBlocking {
dao.save(cookie)
assertEquals(cookie, dao.selectById(cookie.id), "Cookie selection failed")
assertNull(dao.selectById(cookie.id + 1), "Inexistent cookie selection failed")
}
}
}

View File

@ -0,0 +1,54 @@
/*
* Copyright 2019 Allan Wang
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package com.pitchedapps.frost.db
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.runner.RunWith
import org.koin.error.NoBeanDefFoundException
import org.koin.standalone.get
import org.koin.test.KoinTest
import kotlin.reflect.KClass
import kotlin.reflect.full.functions
import kotlin.test.Test
import kotlin.test.assertTrue
@RunWith(AndroidJUnit4::class)
class DatabaseTest : KoinTest {
inline fun <reified T : Any> hasKoin() = hasKoin(T::class)
fun <T : Any> hasKoin(klazz: KClass<T>): Boolean =
try {
get<T>(clazz = klazz)
true
} catch (e: NoBeanDefFoundException) {
false
}
/**
* Database and all daos should be loaded as components
*/
@Test
fun testKoins() {
hasKoin<FrostDatabase>()
val members = FrostDatabase::class.java.kotlin.functions.filter { it.name.endsWith("Dao") }
.mapNotNull { it.returnType.classifier as? KClass<*> }
assertTrue(members.isNotEmpty(), "Failed to find dao interfaces")
val missingKoins = (members + FrostDatabase::class).filter { !hasKoin(it) }
assertTrue(missingKoins.isEmpty(), "Missing koins: $missingKoins")
}
}

View File

@ -0,0 +1,62 @@
/*
* Copyright 2019 Allan Wang
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package com.pitchedapps.frost.db
import com.pitchedapps.frost.facebook.FbItem
import com.pitchedapps.frost.facebook.defaultTabs
import kotlinx.coroutines.runBlocking
import kotlin.test.Test
import kotlin.test.assertEquals
class GenericDbTest : BaseDbTest() {
private val dao get() = db.genericDao()
/**
* Note that order is also preserved here
*/
@Test
fun save() {
val tabs = listOf(FbItem.ACTIVITY_LOG, FbItem.BIRTHDAYS, FbItem.EVENTS, FbItem.MARKETPLACE, FbItem.ACTIVITY_LOG)
runBlocking {
dao.saveTabs(tabs)
assertEquals(tabs, dao.getTabs(), "Tab saving failed")
val newTabs = listOf(FbItem.PAGES, FbItem.MENU)
dao.saveTabs(newTabs)
assertEquals(newTabs, dao.getTabs(), "Tab overwrite failed")
}
}
@Test
fun defaultRetrieve() {
runBlocking {
assertEquals(defaultTabs(), dao.getTabs(), "Default retrieval failed")
}
}
@Test
fun ignoreErrors() {
runBlocking {
dao.save(GenericEntity(GenericDao.TYPE_TABS, "${FbItem.ACTIVITY_LOG.name},unknown,${FbItem.EVENTS.name}"))
assertEquals(
listOf(FbItem.ACTIVITY_LOG, FbItem.EVENTS),
dao.getTabs(),
"Tab fetching does not ignore unknown names"
)
}
}
}

View File

@ -0,0 +1,145 @@
/*
* Copyright 2019 Allan Wang
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package com.pitchedapps.frost.db
import com.pitchedapps.frost.services.NOTIF_CHANNEL_GENERAL
import com.pitchedapps.frost.services.NOTIF_CHANNEL_MESSAGES
import com.pitchedapps.frost.services.NotificationContent
import kotlinx.coroutines.runBlocking
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class NotificationDbTest : BaseDbTest() {
private val dao get() = db.notifDao()
private fun cookie(id: Long) = CookieEntity(id, "name$id", "cookie$id")
private fun notifContent(id: Long, cookie: CookieEntity, time: Long = id) = NotificationContent(
data = cookie,
id = id,
href = "",
title = null,
text = "",
timestamp = time,
profileUrl = null,
unread = true
)
@Test
fun saveAndRetrieve() {
val cookie = cookie(12345L)
// Unique unsorted ids
val notifs = listOf(0L, 4L, 2L, 6L, 99L, 3L).map { notifContent(it, cookie) }
runBlocking {
db.cookieDao().save(cookie)
dao.saveNotifications(NOTIF_CHANNEL_GENERAL, notifs)
val dbNotifs = dao.selectNotifications(cookie.id, NOTIF_CHANNEL_GENERAL)
assertEquals(notifs.sortedByDescending { it.timestamp }, dbNotifs, "Incorrect notification list received")
}
}
@Test
fun selectConditions() {
runBlocking {
val cookie1 = cookie(12345L)
val cookie2 = cookie(12L)
val notifs1 = (0L..2L).map { notifContent(it, cookie1) }
val notifs2 = (5L..10L).map { notifContent(it, cookie2) }
db.cookieDao().save(cookie1)
db.cookieDao().save(cookie2)
dao.saveNotifications(NOTIF_CHANNEL_GENERAL, notifs1)
dao.saveNotifications(NOTIF_CHANNEL_MESSAGES, notifs2)
assertEquals(
emptyList(),
dao.selectNotifications(cookie1.id, NOTIF_CHANNEL_MESSAGES),
"Filtering by type did not work for cookie1"
)
assertEquals(
notifs1.sortedByDescending { it.timestamp },
dao.selectNotifications(cookie1.id, NOTIF_CHANNEL_GENERAL),
"Selection for cookie1 failed"
)
assertEquals(
emptyList(),
dao.selectNotifications(cookie2.id, NOTIF_CHANNEL_GENERAL),
"Filtering by type did not work for cookie2"
)
assertEquals(
notifs2.sortedByDescending { it.timestamp },
dao.selectNotifications(cookie2.id, NOTIF_CHANNEL_MESSAGES),
"Selection for cookie2 failed"
)
}
}
/**
* Primary key is both id and userId, in the event that the same notification to multiple users has the same id
*/
@Test
fun primaryKeyCheck() {
runBlocking {
val cookie1 = cookie(12345L)
val cookie2 = cookie(12L)
val notifs1 = (0L..2L).map { notifContent(it, cookie1) }
val notifs2 = notifs1.map { it.copy(data = cookie2) }
db.cookieDao().save(cookie1)
db.cookieDao().save(cookie2)
assertTrue(dao.saveNotifications(NOTIF_CHANNEL_GENERAL, notifs1), "Notif1 save failed")
assertTrue(dao.saveNotifications(NOTIF_CHANNEL_GENERAL, notifs2), "Notif2 save failed")
}
}
@Test
fun cascadeDeletion() {
val cookie = cookie(12345L)
// Unique unsorted ids
val notifs = listOf(0L, 4L, 2L, 6L, 99L, 3L).map { notifContent(it, cookie) }
runBlocking {
db.cookieDao().save(cookie)
dao.saveNotifications(NOTIF_CHANNEL_GENERAL, notifs)
db.cookieDao().deleteById(cookie.id)
val dbNotifs = dao.selectNotifications(cookie.id, NOTIF_CHANNEL_GENERAL)
assertTrue(dbNotifs.isEmpty(), "Cascade deletion failed")
}
}
@Test
fun latestEpoch() {
val cookie = cookie(12345L)
// Unique unsorted ids
val notifs = listOf(0L, 4L, 2L, 6L, 99L, 3L).map { notifContent(it, cookie) }
runBlocking {
assertEquals(-1L, dao.latestEpoch(cookie.id, NOTIF_CHANNEL_GENERAL), "Default epoch failed")
db.cookieDao().save(cookie)
dao.saveNotifications(NOTIF_CHANNEL_GENERAL, notifs)
assertEquals(99L, dao.latestEpoch(cookie.id, NOTIF_CHANNEL_GENERAL), "Latest epoch failed")
}
}
@Test
fun insertionWithInvalidCookies() {
runBlocking {
assertFalse(
dao.saveNotifications(NOTIF_CHANNEL_GENERAL, listOf(notifContent(1L, cookie(2L)))),
"Notif save should not have passed without relevant cookie entries"
)
}
}
}

View File

@ -174,6 +174,20 @@
</intent-filter>
</receiver>
<!--Widgets-->
<receiver android:name=".widgets.NotificationWidget">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
</intent-filter>
<meta-data
android:name="android.appwidget.provider"
android:resource="@xml/notification_widget_info" />
</receiver>
<service
android:name=".widgets.NotificationWidgetService"
android:enabled="true"
android:permission="android.permission.BIND_REMOTEVIEWS" />
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.provider"

View File

@ -29,9 +29,10 @@ import com.bumptech.glide.request.RequestOptions
import com.bumptech.glide.signature.ApplicationVersionSignature
import com.mikepenz.materialdrawer.util.AbstractDrawerImageLoader
import com.mikepenz.materialdrawer.util.DrawerImageLoader
import com.pitchedapps.frost.dbflow.CookiesDb
import com.pitchedapps.frost.dbflow.FbTabsDb
import com.pitchedapps.frost.dbflow.NotificationDb
import com.pitchedapps.frost.db.CookiesDb
import com.pitchedapps.frost.db.FbTabsDb
import com.pitchedapps.frost.db.FrostDatabase
import com.pitchedapps.frost.db.NotificationDb
import com.pitchedapps.frost.glide.GlideApp
import com.pitchedapps.frost.services.scheduleNotifications
import com.pitchedapps.frost.services.setupNotificationChannels
@ -44,6 +45,7 @@ import com.raizlabs.android.dbflow.config.DatabaseConfig
import com.raizlabs.android.dbflow.config.FlowConfig
import com.raizlabs.android.dbflow.config.FlowManager
import com.raizlabs.android.dbflow.runtime.ContentResolverNotifier
import org.koin.android.ext.android.startKoin
import java.util.Random
import kotlin.reflect.KClass
@ -81,7 +83,7 @@ class FrostApp : Application() {
)
Showcase.initialize(this, "${BuildConfig.APPLICATION_ID}.showcase")
Prefs.initialize(this, "${BuildConfig.APPLICATION_ID}.prefs")
// if (LeakCanary.isInAnalyzerProcess(this)) return
// if (LeakCanary.isInAnalyzerProcess(this)) return
// refWatcher = LeakCanary.install(this)
initBugsnag()
KL.shouldLog = { BuildConfig.DEBUG }
@ -132,6 +134,7 @@ class FrostApp : Application() {
L.d { "Activity ${activity.localClassName} created" }
}
})
startKoin(this, listOf(FrostDatabase.module(this)))
}
private fun initBugsnag() {

View File

@ -32,8 +32,15 @@ import com.mikepenz.google_material_typeface_library.GoogleMaterial
import com.pitchedapps.frost.activities.LoginActivity
import com.pitchedapps.frost.activities.MainActivity
import com.pitchedapps.frost.activities.SelectorActivity
import com.pitchedapps.frost.dbflow.CookieModel
import com.pitchedapps.frost.dbflow.loadFbCookiesSync
import com.pitchedapps.frost.db.CookieDao
import com.pitchedapps.frost.db.CookieEntity
import com.pitchedapps.frost.db.CookieModel
import com.pitchedapps.frost.db.FbTabModel
import com.pitchedapps.frost.db.GenericDao
import com.pitchedapps.frost.db.getTabs
import com.pitchedapps.frost.db.save
import com.pitchedapps.frost.db.saveTabs
import com.pitchedapps.frost.db.selectAll
import com.pitchedapps.frost.facebook.FbCookie
import com.pitchedapps.frost.utils.BiometricUtils
import com.pitchedapps.frost.utils.EXTRA_COOKIES
@ -41,9 +48,12 @@ import com.pitchedapps.frost.utils.L
import com.pitchedapps.frost.utils.Prefs
import com.pitchedapps.frost.utils.launchNewTask
import com.pitchedapps.frost.utils.loadAssets
import com.raizlabs.android.dbflow.kotlinextensions.from
import com.raizlabs.android.dbflow.kotlinextensions.select
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.koin.android.ext.android.inject
import java.util.ArrayList
/**
@ -51,6 +61,9 @@ import java.util.ArrayList
*/
class StartActivity : KauBaseActivity() {
private val cookieDao: CookieDao by inject()
private val genericDao: GenericDao by inject()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@ -69,12 +82,11 @@ class StartActivity : KauBaseActivity() {
launch {
val authDefer = BiometricUtils.authenticate(this@StartActivity)
try {
migrate()
FbCookie.switchBackUser()
val cookies = ArrayList(withContext(Dispatchers.IO) {
loadFbCookiesSync()
})
val cookies = ArrayList(cookieDao.selectAll())
L.i { "Cookies loaded at time ${System.currentTimeMillis()}" }
L._d { "Cookies: ${cookies.joinToString("\t", transform = CookieModel::toSensitiveString)}" }
L._d { "Cookies: ${cookies.joinToString("\t", transform = CookieEntity::toSensitiveString)}" }
loadAssets()
authDefer.await()
when {
@ -88,11 +100,32 @@ class StartActivity : KauBaseActivity() {
})
}
} catch (e: Exception) {
L._e(e) { "Load start failed" }
showInvalidWebView()
}
}
}
/**
* Migrate from dbflow to room
* TODO delete dbflow data
*/
private suspend fun migrate() = withContext(Dispatchers.IO) {
if (cookieDao.selectAll().isNotEmpty()) return@withContext
val cookies = (select from CookieModel::class).queryList().map { CookieEntity(it.id, it.name, it.cookie) }
if (cookies.isNotEmpty()) {
cookieDao.save(cookies)
L._d { "Migrated cookies ${cookieDao.selectAll()}" }
}
val tabs = (select from FbTabModel::class).queryList().map(FbTabModel::tab)
if (tabs.isNotEmpty()) {
genericDao.saveTabs(tabs)
L._d { "Migrated tabs ${genericDao.getTabs()}" }
}
deleteDatabase("Cookies.db")
deleteDatabase("FrostTabs.db")
}
private fun showInvalidWebView() =
showInvalidView(R.string.error_webview)

View File

@ -69,9 +69,10 @@ import com.pitchedapps.frost.contracts.FileChooserContract
import com.pitchedapps.frost.contracts.FileChooserDelegate
import com.pitchedapps.frost.contracts.MainActivityContract
import com.pitchedapps.frost.contracts.VideoViewHolder
import com.pitchedapps.frost.dbflow.TAB_COUNT
import com.pitchedapps.frost.dbflow.loadFbCookie
import com.pitchedapps.frost.dbflow.loadFbTabs
import com.pitchedapps.frost.db.CookieDao
import com.pitchedapps.frost.db.GenericDao
import com.pitchedapps.frost.db.currentCookie
import com.pitchedapps.frost.db.getTabs
import com.pitchedapps.frost.enums.MainActivityLayout
import com.pitchedapps.frost.facebook.FbCookie
import com.pitchedapps.frost.facebook.FbItem
@ -103,12 +104,14 @@ import com.pitchedapps.frost.utils.setFrostColors
import com.pitchedapps.frost.views.BadgedIcon
import com.pitchedapps.frost.views.FrostVideoViewer
import com.pitchedapps.frost.views.FrostViewPager
import com.pitchedapps.frost.widgets.NotificationWidget
import kotlinx.android.synthetic.main.activity_frame_wrapper.*
import kotlinx.android.synthetic.main.view_main_fab.*
import kotlinx.android.synthetic.main.view_main_toolbar.*
import kotlinx.android.synthetic.main.view_main_viewpager.*
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.launch
import org.koin.android.ext.android.inject
/**
* Created by Allan Wang on 20/12/17.
@ -120,9 +123,14 @@ abstract class BaseMainActivity : BaseActivity(), MainActivityContract,
FileChooserContract by FileChooserDelegate(),
VideoViewHolder, SearchViewHolder {
protected lateinit var adapter: SectionsPagerAdapter
/**
* Note that tabs themselves are initialized through a coroutine during onCreate
*/
protected val adapter: SectionsPagerAdapter = SectionsPagerAdapter()
override val frameWrapper: FrameLayout get() = frame_wrapper
val viewPager: FrostViewPager get() = container
val cookieDao: CookieDao by inject()
val genericDao: GenericDao by inject()
/*
* Components with the same id in multiple layout files
@ -131,6 +139,8 @@ abstract class BaseMainActivity : BaseActivity(), MainActivityContract,
val appBar: AppBarLayout by bindView(R.id.appbar)
val coordinator: CoordinatorLayout by bindView(R.id.main_content)
protected var lastPosition = -1
override var videoViewer: FrostVideoViewer? = null
private lateinit var drawer: Drawer
private lateinit var drawerHeader: AccountHeader
@ -151,12 +161,13 @@ abstract class BaseMainActivity : BaseActivity(), MainActivityContract,
background(viewPager)
}
setSupportActionBar(toolbar)
adapter = SectionsPagerAdapter(loadFbTabs())
viewPager.adapter = adapter
viewPager.offscreenPageLimit = TAB_COUNT
tabs.setBackgroundColor(Prefs.mainActivityLayout.backgroundColor())
onNestedCreate(savedInstanceState)
L.i { "Main finished loading UI in ${System.currentTimeMillis() - start} ms" }
launch {
adapter.setPages(genericDao.getTabs())
}
controlWebview = WebView(this)
if (BuildConfig.VERSION_CODE > Prefs.versionCode) {
Prefs.prevVersionCode = Prefs.versionCode
@ -274,27 +285,28 @@ abstract class BaseMainActivity : BaseActivity(), MainActivityContract,
if (current) launchWebOverlay(FbItem.PROFILE.url)
else when (profile.identifier) {
-2L -> {
val currentCookie = loadFbCookie(Prefs.userId)
if (currentCookie == null) {
toast(R.string.account_not_found)
launch {
// TODO no backpressure support
this@BaseMainActivity.launch {
val currentCookie = cookieDao.currentCookie()
if (currentCookie == null) {
toast(R.string.account_not_found)
FbCookie.reset()
launchLogin(cookies(), true)
}
} else {
materialDialogThemed {
title(R.string.kau_logout)
content(
String.format(
string(R.string.kau_logout_confirm_as_x), currentCookie.name
?: Prefs.userId.toString()
} else {
materialDialogThemed {
title(R.string.kau_logout)
content(
String.format(
string(R.string.kau_logout_confirm_as_x),
currentCookie.name ?: Prefs.userId.toString()
)
)
)
positiveText(R.string.kau_yes)
negativeText(R.string.kau_no)
onPositive { _, _ ->
launch {
FbCookie.logout(this@BaseMainActivity)
positiveText(R.string.kau_yes)
negativeText(R.string.kau_no)
onPositive { _, _ ->
this@BaseMainActivity.launch {
FbCookie.logout(this@BaseMainActivity)
}
}
}
}
@ -303,7 +315,7 @@ abstract class BaseMainActivity : BaseActivity(), MainActivityContract,
-3L -> launchNewTask<LoginActivity>(clearStack = false)
-4L -> launchNewTask<SelectorActivity>(cookies(), false)
else -> {
launch {
this@BaseMainActivity.launch {
FbCookie.switchUser(profile.identifier)
tabsForEachView { _, view -> view.badgeText = null }
refreshAll()
@ -371,6 +383,11 @@ abstract class BaseMainActivity : BaseActivity(), MainActivityContract,
R.id.action_settings to GoogleMaterial.Icon.gmd_settings,
R.id.action_search to GoogleMaterial.Icon.gmd_search
)
bindSearchView(menu)
return true
}
private fun bindSearchView(menu: Menu) {
searchViewBindIfNull {
bindSearchView(menu, R.id.action_search, Prefs.iconColor) {
textCallback = { query, searchView ->
@ -402,7 +419,6 @@ abstract class BaseMainActivity : BaseActivity(), MainActivityContract,
onItemClick = { _, key, _, _ -> launchWebOverlay(key) }
}
}
return true
}
@SuppressLint("RestrictedApi")
@ -439,7 +455,11 @@ abstract class BaseMainActivity : BaseActivity(), MainActivityContract,
Runtime.getRuntime().exit(0)
return
}
if (resultCode and REQUEST_RESTART > 0) return restart()
if (resultCode and REQUEST_RESTART > 0) {
NotificationWidget.forceUpdate(this)
restart()
return
}
/*
* These results can be stacked
*/
@ -454,16 +474,12 @@ abstract class BaseMainActivity : BaseActivity(), MainActivityContract,
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
outState.putStringArrayList(STATE_FORCE_FALLBACK, ArrayList(adapter.forcedFallbacks))
adapter.saveInstanceState(outState)
}
override fun onRestoreInstanceState(savedInstanceState: Bundle) {
super.onRestoreInstanceState(savedInstanceState)
adapter.forcedFallbacks.clear()
adapter.forcedFallbacks.addAll(
savedInstanceState.getStringArrayList(STATE_FORCE_FALLBACK)
?: emptyList()
)
adapter.restoreInstanceState(savedInstanceState)
}
override fun onResume() {
@ -496,6 +512,10 @@ abstract class BaseMainActivity : BaseActivity(), MainActivityContract,
}
override fun backConsumer(): Boolean {
if (drawer.isDrawerOpen) {
drawer.closeDrawer()
return true
}
if (currentFragment.onBackPressed()) return true
if (Prefs.exitConfirmation) {
materialDialogThemed {
@ -518,9 +538,48 @@ abstract class BaseMainActivity : BaseActivity(), MainActivityContract,
runOnUiThread { adapter.reloadFragment(fragment) }
}
inner class SectionsPagerAdapter(val pages: List<FbItem>) : FragmentPagerAdapter(supportFragmentManager) {
inner class SectionsPagerAdapter : FragmentPagerAdapter(supportFragmentManager) {
val forcedFallbacks = mutableSetOf<String>()
private val pages: MutableList<FbItem> = mutableListOf()
private val forcedFallbacks = mutableSetOf<String>()
/**
* Update page list and prompt reload
*/
fun setPages(pages: List<FbItem>) {
this.pages.clear()
this.pages.addAll(pages)
notifyDataSetChanged()
tabs.removeAllTabs()
this.pages.forEachIndexed { index, fbItem ->
tabs.addTab(
tabs.newTab()
.setCustomView(BadgedIcon(this@BaseMainActivity).apply { iicon = fbItem.icon }.also {
it.setAllAlpha(if (index == 0) SELECTED_TAB_ALPHA else UNSELECTED_TAB_ALPHA)
})
)
}
lastPosition = 0
viewPager.setCurrentItem(0, false)
viewPager.offscreenPageLimit = pages.size
viewPager.post {
if (!fragmentChannel.isClosedForSend)
fragmentChannel.offer(0)
} //trigger hook so title is set
}
fun saveInstanceState(outState: Bundle) {
outState.putStringArrayList(STATE_FORCE_FALLBACK, ArrayList(forcedFallbacks))
}
fun restoreInstanceState(savedInstanceState: Bundle) {
forcedFallbacks.clear()
forcedFallbacks.addAll(
savedInstanceState.getStringArrayList(STATE_FORCE_FALLBACK)
?: emptyList()
)
}
fun reloadFragment(fragment: BaseFragment) {
if (fragment is WebFragment) return
@ -559,4 +618,9 @@ abstract class BaseMainActivity : BaseActivity(), MainActivityContract,
PointF(0f, toolbar.height.toFloat())
else
PointF(0f, 0f)
companion object {
const val SELECTED_TAB_ALPHA = 255f
const val UNSELECTED_TAB_ALPHA = 128f
}
}

View File

@ -54,6 +54,7 @@ import com.pitchedapps.frost.utils.Prefs
import com.pitchedapps.frost.utils.cookies
import com.pitchedapps.frost.utils.launchNewTask
import com.pitchedapps.frost.utils.loadAssets
import com.pitchedapps.frost.widgets.NotificationWidget
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.launch
@ -171,6 +172,7 @@ class IntroActivity : KauBaseActivity(), ViewPager.PageTransformer, ViewPager.On
override fun finish() {
launch(NonCancellable) {
loadAssets()
NotificationWidget.forceUpdate(this@IntroActivity)
launchNewTask<MainActivity>(cookies(), false)
super.finish()
}

View File

@ -32,9 +32,10 @@ import com.bumptech.glide.load.engine.GlideException
import com.bumptech.glide.request.RequestListener
import com.bumptech.glide.request.target.Target
import com.pitchedapps.frost.R
import com.pitchedapps.frost.dbflow.CookieModel
import com.pitchedapps.frost.dbflow.loadFbCookiesSuspend
import com.pitchedapps.frost.dbflow.saveFbCookie
import com.pitchedapps.frost.db.CookieDao
import com.pitchedapps.frost.db.CookieEntity
import com.pitchedapps.frost.db.save
import com.pitchedapps.frost.db.selectAll
import com.pitchedapps.frost.facebook.FbCookie
import com.pitchedapps.frost.facebook.FbItem
import com.pitchedapps.frost.facebook.profilePictureUrl
@ -58,6 +59,7 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeout
import org.koin.android.ext.android.inject
import java.net.UnknownHostException
import kotlin.coroutines.resume
@ -71,6 +73,7 @@ class LoginActivity : BaseActivity() {
private val swipeRefresh: SwipeRefreshLayout by bindView(R.id.swipe_refresh)
private val textview: AppCompatTextView by bindView(R.id.textview)
private val profile: ImageView by bindView(R.id.profile)
private val cookieDao: CookieDao by inject()
private lateinit var profileLoader: RequestManager
private val refreshChannel = Channel<Boolean>(10)
@ -109,13 +112,13 @@ class LoginActivity : BaseActivity() {
refreshChannel.offer(refreshing)
}
private suspend fun loadInfo(cookie: CookieModel): Unit = withMainContext {
private suspend fun loadInfo(cookie: CookieEntity): Unit = withMainContext {
refresh(true)
val imageDeferred = async { loadProfile(cookie.id) }
val nameDeferred = async { loadUsername(cookie) }
val name: String = nameDeferred.await()
val name: String? = nameDeferred.await()
val foundImage: Boolean = imageDeferred.await()
L._d { "Logged in and received data" }
@ -126,7 +129,7 @@ class LoginActivity : BaseActivity() {
L._i { cookie }
}
textview.text = String.format(getString(R.string.welcome), name)
textview.text = String.format(getString(R.string.welcome), name ?: "")
textview.fadeIn()
frostEvent("Login", "success" to true)
@ -134,7 +137,7 @@ class LoginActivity : BaseActivity() {
* The user may have logged into an account that is already in the database
* We will let the db handle duplicates and load it now after the new account has been saved
*/
val cookies = ArrayList(loadFbCookiesSuspend())
val cookies = ArrayList(cookieDao.selectAll())
delay(1000)
if (Showcase.intro)
launchNewTask<IntroActivity>(cookies, true)
@ -171,23 +174,23 @@ class LoginActivity : BaseActivity() {
}
}
private suspend fun loadUsername(cookie: CookieModel): String = withContext(Dispatchers.IO) {
val result: String = try {
private suspend fun loadUsername(cookie: CookieEntity): String? = withContext(Dispatchers.IO) {
val result: String? = try {
withTimeout(5000) {
frostJsoup(cookie.cookie, FbItem.PROFILE.url).title()
}
} catch (e: Exception) {
if (e !is UnknownHostException)
e.logFrostEvent("Fetch username failed")
""
null
}
if (cookie.name?.isNotBlank() == false && result != cookie.name) {
cookie.name = result
saveFbCookie(cookie)
if (result != null) {
cookieDao.save(cookie.copy(name = result))
return@withContext result
}
cookie.name ?: ""
return@withContext cookie.name
}
override fun backConsumer(): Boolean {

View File

@ -35,7 +35,6 @@ class MainActivity : BaseMainActivity() {
override val fragmentChannel = BroadcastChannel<Int>(10)
override val headerBadgeChannel = BroadcastChannel<String>(Channel.CONFLATED)
var lastPosition = -1
override fun onNestedCreate(savedInstanceState: Bundle?) {
setupTabs()
@ -54,23 +53,18 @@ class MainActivity : BaseMainActivity() {
override fun onPageScrolled(position: Int, positionOffset: Float, positionOffsetPixels: Int) {
super.onPageScrolled(position, positionOffset, positionOffsetPixels)
val delta = positionOffset * (255 - 128).toFloat()
val delta = positionOffset * (SELECTED_TAB_ALPHA - UNSELECTED_TAB_ALPHA)
tabsForEachView { tabPosition, view ->
view.setAllAlpha(
when (tabPosition) {
position -> 255.0f - delta
position + 1 -> 128.0f + delta
else -> 128f
position -> SELECTED_TAB_ALPHA - delta
position + 1 -> UNSELECTED_TAB_ALPHA + delta
else -> UNSELECTED_TAB_ALPHA
}
)
}
}
})
viewPager.post {
if (!fragmentChannel.isClosedForSend)
fragmentChannel.offer(0)
lastPosition = 0
} //trigger hook so title is set
}
private fun setupTabs() {
@ -115,11 +109,5 @@ class MainActivity : BaseMainActivity() {
L.e(e) { "Header badge error" }
}
}
adapter.pages.forEach {
tabs.addTab(
tabs.newTab()
.setCustomView(BadgedIcon(this).apply { iicon = it.icon })
)
}
}
}

View File

@ -25,6 +25,7 @@ import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.ItemTouchHelper
import androidx.recyclerview.widget.RecyclerView
import ca.allanwang.kau.kotlin.lazyContext
import ca.allanwang.kau.utils.launchMain
import ca.allanwang.kau.utils.scaleXY
import ca.allanwang.kau.utils.setIcon
import ca.allanwang.kau.utils.withAlpha
@ -33,14 +34,19 @@ import com.mikepenz.fastadapter_extensions.drag.ItemTouchCallback
import com.mikepenz.fastadapter_extensions.drag.SimpleDragCallback
import com.mikepenz.google_material_typeface_library.GoogleMaterial
import com.pitchedapps.frost.R
import com.pitchedapps.frost.dbflow.TAB_COUNT
import com.pitchedapps.frost.dbflow.loadFbTabs
import com.pitchedapps.frost.dbflow.save
import com.pitchedapps.frost.db.GenericDao
import com.pitchedapps.frost.db.TAB_COUNT
import com.pitchedapps.frost.db.getTabs
import com.pitchedapps.frost.db.saveTabs
import com.pitchedapps.frost.facebook.FbItem
import com.pitchedapps.frost.iitems.TabIItem
import com.pitchedapps.frost.utils.L
import com.pitchedapps.frost.utils.Prefs
import com.pitchedapps.frost.utils.setFrostColors
import kotlinx.android.synthetic.main.activity_tab_customizer.*
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.launch
import org.koin.android.ext.android.inject
import java.util.Collections
/**
@ -48,6 +54,8 @@ import java.util.Collections
*/
class TabCustomizerActivity : BaseActivity() {
private val genericDao: GenericDao by inject()
private val adapter = FastItemAdapter<TabIItem>()
private val wobble = lazyContext { AnimationUtils.loadAnimation(it, R.anim.rotate_delta) }
@ -65,24 +73,30 @@ class TabCustomizerActivity : BaseActivity() {
divider.setBackgroundColor(Prefs.textColor.withAlpha(30))
instructions.setTextColor(Prefs.textColor)
val tabs = loadFbTabs().toMutableList()
val remaining = FbItem.values().filter { it.name[0] != '_' }.toMutableList()
remaining.removeAll(tabs)
tabs.addAll(remaining)
launch {
val tabs = genericDao.getTabs().toMutableList()
L.d { "Tabs $tabs" }
val remaining = FbItem.values().filter { it.name[0] != '_' }.toMutableList()
remaining.removeAll(tabs)
tabs.addAll(remaining)
adapter.set(tabs.map(::TabIItem))
adapter.add(tabs.map(::TabIItem))
bindSwapper(adapter, tab_recycler)
bindSwapper(adapter, tab_recycler)
adapter.withOnClickListener { view, _, _, _ -> view!!.wobble(); true }
adapter.withOnClickListener { view, _, _, _ -> view!!.wobble(); true }
}
setResult(Activity.RESULT_CANCELED)
fab_save.setIcon(GoogleMaterial.Icon.gmd_check, Prefs.iconColor)
fab_save.backgroundTintList = ColorStateList.valueOf(Prefs.accentColor)
fab_save.setOnClickListener {
adapter.adapterItems.subList(0, TAB_COUNT).map(TabIItem::item).save()
setResult(Activity.RESULT_OK)
finish()
launchMain(NonCancellable) {
val tabs = adapter.adapterItems.subList(0, TAB_COUNT).map(TabIItem::item)
genericDao.saveTabs(tabs)
setResult(Activity.RESULT_OK)
finish()
}
}
fab_cancel.setIcon(GoogleMaterial.Icon.gmd_close, Prefs.iconColor)
fab_cancel.backgroundTintList = ColorStateList.valueOf(Prefs.accentColor)

View File

@ -315,8 +315,8 @@ open class WebOverlayActivityBase(private val forceDesktopAgent: Boolean) : Base
override fun onOptionsItemSelected(item: MenuItem): Boolean {
when (item.itemId) {
R.id.action_copy_link -> copyToClipboard(web.currentUrl)
R.id.action_share -> shareText(web.currentUrl)
R.id.action_copy_link -> copyToClipboard(web.currentUrl.formattedFbUrl)
R.id.action_share -> shareText(web.currentUrl.formattedFbUrl)
else -> if (!OverlayContext.onOptionsItemSelected(web, item.itemId))
return super.onOptionsItemSelected(item)
}

View File

@ -0,0 +1,86 @@
/*
* Copyright 2018 Allan Wang
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package com.pitchedapps.frost.db
import android.os.Parcelable
import androidx.room.Dao
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import com.pitchedapps.frost.utils.L
import kotlinx.android.parcel.Parcelize
/**
* Created by Allan Wang on 2017-05-30.
*/
/**
* Generic cache to store serialized content
*/
@Entity(
tableName = "frost_cache",
primaryKeys = ["id", "type"],
foreignKeys = [ForeignKey(
entity = CookieEntity::class,
parentColumns = ["cookie_id"],
childColumns = ["id"],
onDelete = ForeignKey.CASCADE
)]
)
@Parcelize
data class CacheEntity(
val id: Long,
val type: String,
val lastUpdated: Long,
val contents: String
) : Parcelable
@Dao
interface CacheDao {
@Query("SELECT * FROM frost_cache WHERE id = :id AND type = :type")
fun _select(id: Long, type: String): CacheEntity?
@Insert(onConflict = OnConflictStrategy.REPLACE)
fun _insertCache(cache: CacheEntity)
@Query("DELETE FROM frost_cache WHERE id = :id AND type = :type")
fun _delete(id: Long, type: String)
}
suspend fun CacheDao.select(id: Long, type: String) = dao {
_select(id, type)
}
suspend fun CacheDao.delete(id: Long, type: String) = dao {
_delete(id, type)
}
/**
* Returns true if successful, given that there are constraints to the insertion
*/
suspend fun CacheDao.save(id: Long, type: String, contents: String): Boolean = dao {
try {
_insertCache(CacheEntity(id, type, System.currentTimeMillis(), contents))
true
} catch (e: Exception) {
L.e(e) { "Cache save failed for $type" }
false
}
}

View File

@ -0,0 +1,90 @@
/*
* Copyright 2018 Allan Wang
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package com.pitchedapps.frost.db
import android.os.Parcelable
import androidx.room.ColumnInfo
import androidx.room.Dao
import androidx.room.Entity
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import com.pitchedapps.frost.utils.Prefs
import com.raizlabs.android.dbflow.annotation.ConflictAction
import com.raizlabs.android.dbflow.annotation.Database
import com.raizlabs.android.dbflow.annotation.PrimaryKey
import com.raizlabs.android.dbflow.annotation.Table
import com.raizlabs.android.dbflow.structure.BaseModel
import kotlinx.android.parcel.Parcelize
/**
* Created by Allan Wang on 2017-05-30.
*/
@Entity(tableName = "cookies")
@Parcelize
data class CookieEntity(
@androidx.room.PrimaryKey
@ColumnInfo(name = "cookie_id")
val id: Long,
val name: String?,
val cookie: String?
) : Parcelable {
override fun toString(): String = "CookieEntity(${hashCode()})"
fun toSensitiveString(): String = "CookieEntity(id=$id, name=$name, cookie=$cookie)"
}
@Dao
interface CookieDao {
@Query("SELECT * FROM cookies")
fun _selectAll(): List<CookieEntity>
@Query("SELECT * FROM cookies WHERE cookie_id = :id")
fun _selectById(id: Long): CookieEntity?
@Insert(onConflict = OnConflictStrategy.REPLACE)
fun _save(cookie: CookieEntity)
@Insert(onConflict = OnConflictStrategy.REPLACE)
fun _save(cookies: List<CookieEntity>)
@Query("DELETE FROM cookies WHERE cookie_id = :id")
fun _deleteById(id: Long)
}
suspend fun CookieDao.selectAll() = dao { _selectAll() }
suspend fun CookieDao.selectById(id: Long) = dao { _selectById(id) }
suspend fun CookieDao.save(cookie: CookieEntity) = dao { _save(cookie) }
suspend fun CookieDao.save(cookies: List<CookieEntity>) = dao { _save(cookies) }
suspend fun CookieDao.deleteById(id: Long) = dao { _deleteById(id) }
suspend fun CookieDao.currentCookie() = selectById(Prefs.userId)
@Database(version = CookiesDb.VERSION)
object CookiesDb {
const val NAME = "Cookies"
const val VERSION = 2
}
@Parcelize
@Table(database = CookiesDb::class, allFields = true, primaryKeyConflict = ConflictAction.REPLACE)
data class CookieModel(@PrimaryKey var id: Long = -1L, var name: String? = null, var cookie: String? = null) :
BaseModel(), Parcelable {
override fun toString(): String = "CookieModel(${hashCode()})"
}

View File

@ -0,0 +1,28 @@
/*
* Copyright 2019 Allan Wang
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package com.pitchedapps.frost.db
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
/**
* Wraps dao calls to work with coroutines
* Non transactional queries were supposed to be fixed in https://issuetracker.google.com/issues/69474692,
* but it still requires dispatch from a non ui thread.
* This avoids that constraint
*/
suspend inline fun <T> dao(crossinline block: () -> T) = withContext(Dispatchers.IO) { block() }

View File

@ -0,0 +1,106 @@
/*
* Copyright 2019 Allan Wang
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package com.pitchedapps.frost.db
import android.content.Context
import androidx.room.Database
import androidx.room.Room
import androidx.room.RoomDatabase
import com.pitchedapps.frost.BuildConfig
import org.koin.dsl.module.module
import org.koin.standalone.StandAloneContext
interface FrostPrivateDao {
fun cookieDao(): CookieDao
fun notifDao(): NotificationDao
fun cacheDao(): CacheDao
}
@Database(
entities = [CookieEntity::class, NotificationEntity::class, CacheEntity::class],
version = 1,
exportSchema = true
)
abstract class FrostPrivateDatabase : RoomDatabase(), FrostPrivateDao {
companion object {
const val DATABASE_NAME = "frost-priv-db"
}
}
interface FrostPublicDao {
fun genericDao(): GenericDao
}
@Database(entities = [GenericEntity::class], version = 1, exportSchema = true)
abstract class FrostPublicDatabase : RoomDatabase(), FrostPublicDao {
companion object {
const val DATABASE_NAME = "frost-db"
}
}
interface FrostDao : FrostPrivateDao, FrostPublicDao {
fun close()
}
/**
* Composition of all database interfaces
*/
class FrostDatabase(private val privateDb: FrostPrivateDatabase, private val publicDb: FrostPublicDatabase) :
FrostDao,
FrostPrivateDao by privateDb,
FrostPublicDao by publicDb {
override fun close() {
privateDb.close()
publicDb.close()
}
companion object {
private fun <T : RoomDatabase> RoomDatabase.Builder<T>.frostBuild() = if (BuildConfig.DEBUG) {
fallbackToDestructiveMigration().build()
} else {
build()
}
fun create(context: Context): FrostDatabase {
val privateDb = Room.databaseBuilder(
context, FrostPrivateDatabase::class.java,
FrostPrivateDatabase.DATABASE_NAME
).frostBuild()
val publicDb = Room.databaseBuilder(
context, FrostPublicDatabase::class.java,
FrostPublicDatabase.DATABASE_NAME
).frostBuild()
return FrostDatabase(privateDb, publicDb)
}
fun module(context: Context) = module {
single { create(context) }
single { get<FrostDatabase>().cookieDao() }
single { get<FrostDatabase>().cacheDao() }
single { get<FrostDatabase>().notifDao() }
single { get<FrostDatabase>().genericDao() }
}
/**
* Get from koin
* For the most part, you can retrieve directly from other koin components
*/
fun get(): FrostDatabase = StandAloneContext.getKoin().koinContext.get()
}
}

View File

@ -14,23 +14,18 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package com.pitchedapps.frost.dbflow
package com.pitchedapps.frost.db
import com.pitchedapps.frost.facebook.FbItem
import com.pitchedapps.frost.facebook.defaultTabs
import com.pitchedapps.frost.utils.L
import com.raizlabs.android.dbflow.annotation.Database
import com.raizlabs.android.dbflow.annotation.PrimaryKey
import com.raizlabs.android.dbflow.annotation.Table
import com.raizlabs.android.dbflow.kotlinextensions.database
import com.raizlabs.android.dbflow.kotlinextensions.fastSave
import com.raizlabs.android.dbflow.kotlinextensions.from
import com.raizlabs.android.dbflow.kotlinextensions.select
import com.raizlabs.android.dbflow.structure.BaseModel
/**
* Created by Allan Wang on 2017-05-30.
*/
const val TAB_COUNT = 4
@Database(version = FbTabsDb.VERSION)
@ -41,18 +36,3 @@ object FbTabsDb {
@Table(database = FbTabsDb::class, allFields = true)
data class FbTabModel(@PrimaryKey var position: Int = -1, var tab: FbItem = FbItem.FEED) : BaseModel()
/**
* Load tabs synchronously
* Note that tab length should never be a big number anyways
*/
fun loadFbTabs(): List<FbItem> {
val tabs: List<FbTabModel>? = (select from (FbTabModel::class)).orderBy(FbTabModel_Table.position, true).queryList()
if (tabs?.size == TAB_COUNT) return tabs.map(FbTabModel::tab)
L.d { "No tabs (${tabs?.size}); loading default" }
return defaultTabs()
}
fun List<FbItem>.save() {
database<FbTabsDb>().beginTransactionAsync(mapIndexed(::FbTabModel).fastSave().build()).execute()
}

View File

@ -0,0 +1,71 @@
/*
* Copyright 2018 Allan Wang
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package com.pitchedapps.frost.db
import androidx.room.Dao
import androidx.room.Entity
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.PrimaryKey
import androidx.room.Query
import com.pitchedapps.frost.facebook.FbItem
import com.pitchedapps.frost.facebook.defaultTabs
/**
* Created by Allan Wang on 2017-05-30.
*/
/**
* Generic cache to store serialized content
*/
@Entity(tableName = "frost_generic")
data class GenericEntity(
@PrimaryKey
val type: String,
val contents: String
)
@Dao
interface GenericDao {
@Query("SELECT contents FROM frost_generic WHERE type = :type")
fun _select(type: String): String?
@Insert(onConflict = OnConflictStrategy.REPLACE)
fun _save(entity: GenericEntity)
@Query("DELETE FROM frost_generic WHERE type = :type")
fun _delete(type: String)
companion object {
const val TYPE_TABS = "generic_tabs"
}
}
suspend fun GenericDao.saveTabs(tabs: List<FbItem>) = dao {
val content = tabs.joinToString(",") { it.name }
_save(GenericEntity(GenericDao.TYPE_TABS, content))
}
suspend fun GenericDao.getTabs(): List<FbItem> = dao {
val allTabs = FbItem.values.map { it.name to it }.toMap()
_select(GenericDao.TYPE_TABS)
?.split(",")
?.mapNotNull { allTabs[it] }
?.takeIf { it.isNotEmpty() }
?: defaultTabs()
}

View File

@ -0,0 +1,202 @@
/*
* Copyright 2018 Allan Wang
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package com.pitchedapps.frost.db
import androidx.room.ColumnInfo
import androidx.room.Dao
import androidx.room.Embedded
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.Index
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import androidx.room.Transaction
import com.pitchedapps.frost.services.NOTIF_CHANNEL_GENERAL
import com.pitchedapps.frost.services.NOTIF_CHANNEL_MESSAGES
import com.pitchedapps.frost.services.NotificationContent
import com.pitchedapps.frost.utils.L
import com.raizlabs.android.dbflow.annotation.ConflictAction
import com.raizlabs.android.dbflow.annotation.Database
import com.raizlabs.android.dbflow.annotation.Migration
import com.raizlabs.android.dbflow.annotation.PrimaryKey
import com.raizlabs.android.dbflow.annotation.Table
import com.raizlabs.android.dbflow.kotlinextensions.eq
import com.raizlabs.android.dbflow.kotlinextensions.from
import com.raizlabs.android.dbflow.kotlinextensions.select
import com.raizlabs.android.dbflow.kotlinextensions.where
import com.raizlabs.android.dbflow.sql.SQLiteType
import com.raizlabs.android.dbflow.sql.migration.AlterTableMigration
import com.raizlabs.android.dbflow.structure.BaseModel
@Entity(
tableName = "notifications",
primaryKeys = ["notif_id", "userId"],
foreignKeys = [ForeignKey(
entity = CookieEntity::class,
parentColumns = ["cookie_id"],
childColumns = ["userId"],
onDelete = ForeignKey.CASCADE
)],
indices = [Index("notif_id"), Index("userId")]
)
data class NotificationEntity(
@ColumnInfo(name = "notif_id")
val id: Long,
val userId: Long,
val href: String,
val title: String?,
val text: String,
val timestamp: Long,
val profileUrl: String?,
// Type essentially refers to channel
val type: String,
val unread: Boolean
) {
constructor(
type: String,
content: NotificationContent
) : this(
content.id,
content.data.id,
content.href,
content.title,
content.text,
content.timestamp,
content.profileUrl,
type,
content.unread
)
}
data class NotificationContentEntity(
@Embedded
val cookie: CookieEntity,
@Embedded
val notif: NotificationEntity
) {
fun toNotifContent() = NotificationContent(
data = cookie,
id = notif.id,
href = notif.href,
title = notif.title,
text = notif.text,
timestamp = notif.timestamp,
profileUrl = notif.profileUrl,
unread = notif.unread
)
}
@Dao
interface NotificationDao {
/**
* Note that notifications are guaranteed to be ordered by descending timestamp
*/
@Transaction
@Query("SELECT * FROM cookies INNER JOIN notifications ON cookie_id = userId WHERE userId = :userId AND type = :type ORDER BY timestamp DESC")
fun _selectNotifications(userId: Long, type: String): List<NotificationContentEntity>
@Query("SELECT timestamp FROM notifications WHERE userId = :userId AND type = :type ORDER BY timestamp DESC LIMIT 1")
fun _selectEpoch(userId: Long, type: String): Long?
@Insert(onConflict = OnConflictStrategy.REPLACE)
fun _insertNotifications(notifs: List<NotificationEntity>)
@Query("DELETE FROM notifications WHERE userId = :userId AND type = :type")
fun _deleteNotifications(userId: Long, type: String)
@Query("DELETE FROM notifications")
fun _deleteAll()
/**
* It is assumed that the notification batch comes from the same user
*/
@Transaction
fun _saveNotifications(type: String, notifs: List<NotificationContent>) {
val userId = notifs.firstOrNull()?.data?.id ?: return
val entities = notifs.map { NotificationEntity(type, it) }
_deleteNotifications(userId, type)
_insertNotifications(entities)
}
}
suspend fun NotificationDao.deleteAll() = dao { _deleteAll() }
fun NotificationDao.selectNotificationsSync(userId: Long, type: String): List<NotificationContent> =
_selectNotifications(userId, type).map { it.toNotifContent() }
suspend fun NotificationDao.selectNotifications(userId: Long, type: String): List<NotificationContent> = dao {
selectNotificationsSync(userId, type)
}
/**
* Returns true if successful, given that there are constraints to the insertion
*/
suspend fun NotificationDao.saveNotifications(type: String, notifs: List<NotificationContent>): Boolean {
if (notifs.isEmpty()) return true
return dao {
try {
_saveNotifications(type, notifs)
true
} catch (e: Exception) {
L.e(e) { "Notif save failed for $type" }
false
}
}
}
suspend fun NotificationDao.latestEpoch(userId: Long, type: String): Long = dao {
_selectEpoch(userId, type) ?: lastNotificationTime(userId).let {
when (type) {
NOTIF_CHANNEL_GENERAL -> it.epoch
NOTIF_CHANNEL_MESSAGES -> it.epochIm
else -> -1L
}
}
}
/**
* Created by Allan Wang on 2017-05-30.
*/
@Database(version = NotificationDb.VERSION)
object NotificationDb {
const val NAME = "Notifications"
const val VERSION = 2
}
@Migration(version = 2, database = NotificationDb::class)
class NotificationMigration2(modelClass: Class<NotificationModel>) :
AlterTableMigration<NotificationModel>(modelClass) {
override fun onPreMigrate() {
super.onPreMigrate()
addColumn(SQLiteType.INTEGER, "epochIm")
L.d { "Added column" }
}
}
@Table(database = NotificationDb::class, allFields = true, primaryKeyConflict = ConflictAction.REPLACE)
data class NotificationModel(
@PrimaryKey var id: Long = -1L,
var epoch: Long = -1L,
var epochIm: Long = -1L
) : BaseModel()
internal fun lastNotificationTime(id: Long): NotificationModel =
(select from NotificationModel::class where (NotificationModel_Table.id eq id)).querySingle()
?: NotificationModel(id = id)

View File

@ -1,92 +0,0 @@
/*
* Copyright 2018 Allan Wang
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package com.pitchedapps.frost.dbflow
import android.os.Parcelable
import com.pitchedapps.frost.utils.L
import com.raizlabs.android.dbflow.annotation.ConflictAction
import com.raizlabs.android.dbflow.annotation.Database
import com.raizlabs.android.dbflow.annotation.PrimaryKey
import com.raizlabs.android.dbflow.annotation.Table
import com.raizlabs.android.dbflow.kotlinextensions.async
import com.raizlabs.android.dbflow.kotlinextensions.delete
import com.raizlabs.android.dbflow.kotlinextensions.eq
import com.raizlabs.android.dbflow.kotlinextensions.from
import com.raizlabs.android.dbflow.kotlinextensions.save
import com.raizlabs.android.dbflow.kotlinextensions.select
import com.raizlabs.android.dbflow.kotlinextensions.where
import com.raizlabs.android.dbflow.structure.BaseModel
import kotlinx.android.parcel.Parcelize
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
/**
* Created by Allan Wang on 2017-05-30.
*/
@Database(version = CookiesDb.VERSION)
object CookiesDb {
const val NAME = "Cookies"
const val VERSION = 2
}
@Parcelize
@Table(database = CookiesDb::class, allFields = true, primaryKeyConflict = ConflictAction.REPLACE)
data class CookieModel(@PrimaryKey var id: Long = -1L, var name: String? = null, var cookie: String? = null) :
BaseModel(), Parcelable {
override fun toString(): String = "CookieModel(${hashCode()})"
fun toSensitiveString(): String = "CookieModel(id=$id, name=$name, cookie=$cookie)"
}
fun loadFbCookie(id: Long): CookieModel? =
(select from CookieModel::class where (CookieModel_Table.id eq id)).querySingle()
fun loadFbCookie(name: String): CookieModel? =
(select from CookieModel::class where (CookieModel_Table.name eq name)).querySingle()
/**
* Loads cookies sorted by name
*/
fun loadFbCookiesAsync(callback: (cookies: List<CookieModel>) -> Unit) {
(select from CookieModel::class).orderBy(CookieModel_Table.name, true).async()
.queryListResultCallback { _, tResult -> callback(tResult) }.execute()
}
fun loadFbCookiesSync(): List<CookieModel> =
(select from CookieModel::class).orderBy(CookieModel_Table.name, true).queryList()
// TODO temp method until dbflow supports coroutines
suspend fun loadFbCookiesSuspend(): List<CookieModel> = withContext(Dispatchers.IO) {
loadFbCookiesSync()
}
inline fun saveFbCookie(cookie: CookieModel, crossinline callback: (() -> Unit) = {}) {
cookie.async save {
L.d { "Fb cookie saved" }
L._d { cookie.toSensitiveString() }
callback()
}
}
fun removeCookie(id: Long) {
loadFbCookie(id)?.async?.delete {
L.d { "Fb cookie deleted" }
L._d { id }
}
}

View File

@ -1,39 +0,0 @@
/*
* Copyright 2018 Allan Wang
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package com.pitchedapps.frost.dbflow
import android.content.Context
import com.pitchedapps.frost.utils.L
import com.raizlabs.android.dbflow.config.FlowManager
import com.raizlabs.android.dbflow.structure.database.transaction.FastStoreModelTransaction
/**
* Created by Allan Wang on 2017-05-30.
*/
object DbUtils {
fun db(name: String) = FlowManager.getDatabase(name)
fun dbName(name: String) = "$name.db"
fun deleteDatabase(c: Context, name: String) = c.deleteDatabase(dbName(name))
}
inline fun <reified T : Any> List<T>.replace(dbName: String) {
L.d { "Replacing $dbName.db" }
DbUtils.db(dbName).reset()
FastStoreModelTransaction.saveBuilder(FlowManager.getModelAdapter(T::class.java)).addAll(this).build()
}

View File

@ -1,72 +0,0 @@
/*
* Copyright 2018 Allan Wang
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package com.pitchedapps.frost.dbflow
import com.pitchedapps.frost.utils.L
import com.raizlabs.android.dbflow.annotation.ConflictAction
import com.raizlabs.android.dbflow.annotation.Database
import com.raizlabs.android.dbflow.annotation.Migration
import com.raizlabs.android.dbflow.annotation.PrimaryKey
import com.raizlabs.android.dbflow.annotation.Table
import com.raizlabs.android.dbflow.kotlinextensions.async
import com.raizlabs.android.dbflow.kotlinextensions.eq
import com.raizlabs.android.dbflow.kotlinextensions.from
import com.raizlabs.android.dbflow.kotlinextensions.save
import com.raizlabs.android.dbflow.kotlinextensions.select
import com.raizlabs.android.dbflow.kotlinextensions.where
import com.raizlabs.android.dbflow.sql.SQLiteType
import com.raizlabs.android.dbflow.sql.migration.AlterTableMigration
import com.raizlabs.android.dbflow.structure.BaseModel
/**
* Created by Allan Wang on 2017-05-30.
*/
@Database(version = NotificationDb.VERSION)
object NotificationDb {
const val NAME = "Notifications"
const val VERSION = 2
}
@Migration(version = 2, database = NotificationDb::class)
class NotificationMigration2(modelClass: Class<NotificationModel>) :
AlterTableMigration<NotificationModel>(modelClass) {
override fun onPreMigrate() {
super.onPreMigrate()
addColumn(SQLiteType.INTEGER, "epochIm")
L.d { "Added column" }
}
}
@Table(database = NotificationDb::class, allFields = true, primaryKeyConflict = ConflictAction.REPLACE)
data class NotificationModel(
@PrimaryKey var id: Long = -1L,
var epoch: Long = -1L,
var epochIm: Long = -1L
) : BaseModel()
fun lastNotificationTime(id: Long): NotificationModel =
(select from NotificationModel::class where (NotificationModel_Table.id eq id)).querySingle()
?: NotificationModel(id = id)
fun saveNotificationTime(notificationModel: NotificationModel, callback: (() -> Unit)? = null) {
notificationModel.async save {
L.d { "Fb notification model saved" }
L._d { notificationModel }
callback?.invoke()
}
}

View File

@ -19,10 +19,12 @@ package com.pitchedapps.frost.facebook
import android.app.Activity
import android.content.Context
import android.webkit.CookieManager
import com.pitchedapps.frost.dbflow.CookieModel
import com.pitchedapps.frost.dbflow.loadFbCookie
import com.pitchedapps.frost.dbflow.removeCookie
import com.pitchedapps.frost.dbflow.saveFbCookie
import com.pitchedapps.frost.db.CookieDao
import com.pitchedapps.frost.db.CookieEntity
import com.pitchedapps.frost.db.FrostDatabase
import com.pitchedapps.frost.db.deleteById
import com.pitchedapps.frost.db.save
import com.pitchedapps.frost.db.selectById
import com.pitchedapps.frost.utils.L
import com.pitchedapps.frost.utils.Prefs
import com.pitchedapps.frost.utils.cookies
@ -50,6 +52,10 @@ object FbCookie {
inline val webCookie: String?
get() = CookieManager.getInstance().getCookie(COOKIE_DOMAIN)
private val cookieDao: CookieDao by lazy {
FrostDatabase.get().cookieDao()
}
private suspend fun CookieManager.suspendSetWebCookie(cookie: String?): Boolean {
cookie ?: return true
return withContext(NonCancellable) {
@ -77,12 +83,12 @@ object FbCookie {
}
}
fun save(id: Long) {
suspend fun save(id: Long) {
L.d { "New cookie found" }
Prefs.userId = id
CookieManager.getInstance().flush()
val cookie = CookieModel(Prefs.userId, "", webCookie)
saveFbCookie(cookie)
val cookie = CookieEntity(Prefs.userId, null, webCookie)
cookieDao.save(cookie)
}
suspend fun reset() {
@ -93,11 +99,12 @@ object FbCookie {
}
}
suspend fun switchUser(id: Long) = switchUser(loadFbCookie(id))
suspend fun switchUser(id: Long) {
val cookie = cookieDao.selectById(id) ?: return L.e { "No cookie for id" }
switchUser(cookie)
}
suspend fun switchUser(name: String) = switchUser(loadFbCookie(name))
suspend fun switchUser(cookie: CookieModel?) {
suspend fun switchUser(cookie: CookieEntity?) {
if (cookie == null) {
L.d { "Switching User; null cookie" }
return
@ -114,7 +121,7 @@ object FbCookie {
* and launch the proper login page
*/
suspend fun logout(context: Context) {
val cookies = arrayListOf<CookieModel>()
val cookies = arrayListOf<CookieEntity>()
if (context is Activity)
cookies.addAll(context.cookies().filter { it.id != Prefs.userId })
logout(Prefs.userId)
@ -126,7 +133,9 @@ object FbCookie {
*/
suspend fun logout(id: Long) {
L.d { "Logging out user" }
removeCookie(id)
cookieDao.deleteById(id)
L.d { "Fb cookie deleted" }
L._d { id }
reset()
}

View File

@ -56,6 +56,9 @@ enum class FbItem(
PHOTOS(R.string.photos, GoogleMaterial.Icon.gmd_photo, "me/photos"),
PROFILE(R.string.profile, CommunityMaterial.Icon.cmd_account, "me"),
SAVED(R.string.saved, GoogleMaterial.Icon.gmd_bookmark, "saved"),
/**
* Note that this url only works if a query (?q=) is provided
*/
_SEARCH(R.string.kau_search, GoogleMaterial.Icon.gmd_search, "search/top"),
SETTINGS(R.string.settings, GoogleMaterial.Icon.gmd_settings, "settings"),
;

View File

@ -16,7 +16,7 @@
*/
package com.pitchedapps.frost.facebook.parsers
import com.pitchedapps.frost.dbflow.CookieModel
import com.pitchedapps.frost.db.CookieEntity
import com.pitchedapps.frost.facebook.FB_CSS_URL_MATCHER
import com.pitchedapps.frost.facebook.formattedFbUrl
import com.pitchedapps.frost.facebook.get
@ -38,7 +38,7 @@ import org.jsoup.select.Elements
* The return type must be nonnull if no parsing errors occurred, as null signifies a parse error
* If null really must be allowed, use Optionals
*/
interface FrostParser<out T : Any> {
interface FrostParser<out T : ParseData> {
/**
* Name associated to parser
@ -76,12 +76,16 @@ const val FALLBACK_TIME_MOD = 1000000
data class FrostLink(val text: String, val href: String)
data class ParseResponse<out T>(val cookie: String, val data: T) {
data class ParseResponse<out T: ParseData>(val cookie: String, val data: T) {
override fun toString() = "ParseResponse\ncookie: $cookie\ndata:\n$data"
}
interface ParseNotification {
fun getUnreadNotifications(data: CookieModel): List<NotificationContent>
interface ParseData {
val isEmpty: Boolean
}
interface ParseNotification : ParseData {
fun getUnreadNotifications(data: CookieEntity): List<NotificationContent>
}
internal fun <T> List<T>.toJsonString(tag: String, indent: Int) = StringBuilder().apply {
@ -95,7 +99,7 @@ internal fun <T> List<T>.toJsonString(tag: String, indent: Int) = StringBuilder(
* 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> {
internal abstract class FrostParserBase<out T : ParseData>(private val redirectToText: Boolean) : FrostParser<T> {
final override fun parse(cookie: String?) = parseFromUrl(cookie, url)

View File

@ -16,7 +16,7 @@
*/
package com.pitchedapps.frost.facebook.parsers
import com.pitchedapps.frost.dbflow.CookieModel
import com.pitchedapps.frost.db.CookieEntity
import com.pitchedapps.frost.facebook.FB_EPOCH_MATCHER
import com.pitchedapps.frost.facebook.FB_MESSAGE_NOTIF_ID_MATCHER
import com.pitchedapps.frost.facebook.FbItem
@ -46,6 +46,10 @@ data class FrostMessages(
val seeMore: FrostLink?,
val extraLinks: List<FrostLink>
) : ParseNotification {
override val isEmpty: Boolean
get() = threads.isEmpty()
override fun toString() = StringBuilder().apply {
append("FrostMessages {\n")
append(threads.toJsonString("threads", 1))
@ -54,7 +58,7 @@ data class FrostMessages(
append("}")
}.toString()
override fun getUnreadNotifications(data: CookieModel) =
override fun getUnreadNotifications(data: CookieEntity) =
threads.asSequence().filter(FrostThread::unread).map {
with(it) {
NotificationContent(
@ -64,7 +68,8 @@ data class FrostMessages(
title = title,
text = content ?: "",
timestamp = time,
profileUrl = img
profileUrl = img,
unread = unread
)
}
}.toList()

View File

@ -16,7 +16,7 @@
*/
package com.pitchedapps.frost.facebook.parsers
import com.pitchedapps.frost.dbflow.CookieModel
import com.pitchedapps.frost.db.CookieEntity
import com.pitchedapps.frost.facebook.FB_EPOCH_MATCHER
import com.pitchedapps.frost.facebook.FB_NOTIF_ID_MATCHER
import com.pitchedapps.frost.facebook.FbItem
@ -36,6 +36,10 @@ data class FrostNotifs(
val notifs: List<FrostNotif>,
val seeMore: FrostLink?
) : ParseNotification {
override val isEmpty: Boolean
get() = notifs.isEmpty()
override fun toString() = StringBuilder().apply {
append("FrostNotifs {\n")
append(notifs.toJsonString("notifs", 1))
@ -43,7 +47,7 @@ data class FrostNotifs(
append("}")
}.toString()
override fun getUnreadNotifications(data: CookieModel) =
override fun getUnreadNotifications(data: CookieEntity) =
notifs.asSequence().filter(FrostNotif::unread).map {
with(it) {
NotificationContent(
@ -53,7 +57,8 @@ data class FrostNotifs(
title = null,
text = content,
timestamp = time,
profileUrl = img
profileUrl = img,
unread = unread
)
}
}.toList()

View File

@ -40,7 +40,10 @@ enum class SearchKeys(val key: String) {
EVENTS("keywords_events")
}
data class FrostSearches(val results: List<FrostSearch>) {
data class FrostSearches(val results: List<FrostSearch>) : ParseData {
override val isEmpty: Boolean
get() = results.isEmpty()
override fun toString() = StringBuilder().apply {
append("FrostSearches {\n")

View File

@ -25,6 +25,7 @@ import com.mikepenz.fastadapter.adapters.ModelAdapter
import com.pitchedapps.frost.R
import com.pitchedapps.frost.facebook.FbCookie
import com.pitchedapps.frost.facebook.parsers.FrostParser
import com.pitchedapps.frost.facebook.parsers.ParseData
import com.pitchedapps.frost.facebook.parsers.ParseResponse
import com.pitchedapps.frost.utils.L
import com.pitchedapps.frost.utils.frostJsoup
@ -94,7 +95,7 @@ abstract class GenericRecyclerFragment<T, Item : IItem<*, *>> : RecyclerFragment
open fun getAdapter(): FastAdapter<IItem<*, *>> = fastAdapter(this.adapter)
}
abstract class FrostParserFragment<T : Any, Item : IItem<*, *>> : RecyclerFragment<Item, Item>() {
abstract class FrostParserFragment<T : ParseData, Item : IItem<*, *>> : RecyclerFragment<Item, Item>() {
/**
* The parser to make this all happen

View File

@ -31,9 +31,10 @@ import ca.allanwang.kau.utils.string
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.db.CookieEntity
import com.pitchedapps.frost.db.FrostDatabase
import com.pitchedapps.frost.db.latestEpoch
import com.pitchedapps.frost.db.saveNotifications
import com.pitchedapps.frost.enums.OverlayContext
import com.pitchedapps.frost.facebook.FbItem
import com.pitchedapps.frost.facebook.parsers.FrostParser
@ -60,12 +61,10 @@ private val _40_DP = 40.dpToPx
* Enum to handle notification creations
*/
enum class NotificationType(
private val channelId: String,
val channelId: String,
private val overlayContext: OverlayContext,
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
) {
@ -74,8 +73,6 @@ enum class NotificationType(
OverlayContext.NOTIFICATION,
FbItem.NOTIFICATIONS,
NotifParser,
NotificationModel::epoch,
{ notif, time -> notif.copy(epoch = time) },
Prefs::notificationRingtone
) {
@ -88,8 +85,6 @@ enum class NotificationType(
OverlayContext.MESSAGE,
FbItem.MESSAGES,
MessageParser,
NotificationModel::epochIm,
{ notif, time -> notif.copy(epochIm = time) },
Prefs::messageRingtone
);
@ -100,8 +95,8 @@ enum class NotificationType(
*/
internal open fun bindRequest(content: NotificationContent, cookie: String): (BaseBundle.() -> Unit)? = null
private fun bindRequest(intent: Intent, content: NotificationContent, cookie: String?) {
cookie ?: return
private fun bindRequest(intent: Intent, content: NotificationContent) {
val cookie = content.data.cookie ?: return
val binder = bindRequest(content, cookie) ?: return
val bundle = Bundle()
bundle.binder()
@ -116,7 +111,8 @@ enum class NotificationType(
* Returns the number of notifications generated,
* or -1 if an error occurred
*/
fun fetch(context: Context, data: CookieModel): Int {
suspend fun fetch(context: Context, data: CookieEntity): Int {
val notifDao = FrostDatabase.get().notifDao()
val response = try {
parser.parse(data.cookie)
} catch (ignored: Exception) {
@ -142,36 +138,42 @@ enum class NotificationType(
}
if (notifContents.isEmpty()) return 0
val userId = data.id
val prevNotifTime = lastNotificationTime(userId)
val prevLatestEpoch = getTime(prevNotifTime)
// Legacy, remove with dbflow
val prevLatestEpoch = notifDao.latestEpoch(userId, channelId)
L.v { "Notif $name prev epoch $prevLatestEpoch" }
var newLatestEpoch = prevLatestEpoch
val notifs = mutableListOf<FrostNotification>()
notifContents.forEach { notif ->
L.v { "Notif timestamp ${notif.timestamp}" }
if (notif.timestamp <= prevLatestEpoch) return@forEach
notifs.add(createNotification(context, notif))
if (notif.timestamp > newLatestEpoch)
newLatestEpoch = notif.timestamp
}
if (newLatestEpoch > prevLatestEpoch)
putTime(prevNotifTime, newLatestEpoch).save()
L.d { "Notif $name new epoch ${getTime(lastNotificationTime(userId))}" }
if (prevLatestEpoch == -1L && !BuildConfig.DEBUG) {
L.d { "Skipping first notification fetch" }
return 0 // do not notify the first time
}
val newNotifContents = notifContents.filter { it.timestamp > prevLatestEpoch }
if (newNotifContents.isEmpty()) {
L.d { "No new notifs found for $name" }
return 0
}
L.d { "${newNotifContents.size} new notifs found for $name" }
if (!notifDao.saveNotifications(channelId, newNotifContents)) {
L.d { "Skip notifs for $name as saving failed" }
return 0
}
val notifs = newNotifContents.map { createNotification(context, it) }
frostEvent("Notifications", "Type" to name, "Count" to notifs.size)
if (notifs.size > 1)
summaryNotification(context, userId, notifs.size).notify(context)
val ringtone = ringtone()
notifs.forEachIndexed { i, notif ->
// Ring at most twice
notif.withAlert(i < 2, ringtone).notify(context)
}
return notifs.size
}
fun debugNotification(context: Context, data: CookieModel) {
fun debugNotification(context: Context, data: CookieEntity) {
val content = NotificationContent(
data,
System.currentTimeMillis(),
@ -179,23 +181,40 @@ enum class NotificationType(
"Debug Notif",
"Test 123",
System.currentTimeMillis() / 1000,
"https://www.iconexperience.com/_img/v_collection_png/256x256/shadow/dog.png"
"https://www.iconexperience.com/_img/v_collection_png/256x256/shadow/dog.png",
false
)
createNotification(context, content).notify(context)
}
/**
* Attach content related data to an intent
*/
fun putContentExtra(intent: Intent, content: NotificationContent): Intent {
// We will show the notification page for dependent urls. We can trigger a click next time
intent.data = Uri.parse(if (content.href.isIndependent) content.href else FbItem.NOTIFICATIONS.url)
bindRequest(intent, content)
return intent
}
/**
* Create a generic content for the provided type and user id.
* No content related data is added
*/
fun createCommonIntent(context: Context, userId: Long): Intent {
val intent = Intent(context, FrostWebActivity::class.java)
intent.putExtra(ARG_USER_ID, userId)
overlayContext.put(intent)
return intent
}
/**
* Create and submit a new notification with the given [content]
*/
private fun createNotification(context: Context, content: NotificationContent): FrostNotification =
with(content) {
val intent = Intent(context, FrostWebActivity::class.java)
// TODO temp fix; we will show notification page for dependent urls. We can trigger a click next time
intent.data = Uri.parse(if (href.isIndependent) href else FbItem.NOTIFICATIONS.url)
intent.putExtra(ARG_USER_ID, data.id)
overlayContext.put(intent)
bindRequest(intent, content, data.cookie)
val intent = createCommonIntent(context, content.data.id)
putContentExtra(intent, content)
val group = "${groupPrefix}_${data.id}"
val pendingIntent = PendingIntent.getActivity(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT)
val notifBuilder = context.frostNotification(channelId)
@ -257,13 +276,15 @@ enum class NotificationType(
* Notification data holder
*/
data class NotificationContent(
val data: CookieModel,
// TODO replace data with userId?
val data: CookieEntity,
val id: Long,
val href: String,
val title: String? = null, // defaults to frost title
val text: String,
val timestamp: Long,
val profileUrl: String?
val profileUrl: String?,
val unread: Boolean
) {
val notifId = Math.abs(id.toInt())

View File

@ -21,16 +21,19 @@ import androidx.core.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.loadFbCookiesSync
import com.pitchedapps.frost.db.CookieDao
import com.pitchedapps.frost.db.CookieEntity
import com.pitchedapps.frost.db.selectAll
import com.pitchedapps.frost.utils.L
import com.pitchedapps.frost.utils.Prefs
import com.pitchedapps.frost.utils.frostEvent
import com.pitchedapps.frost.widgets.NotificationWidget
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.coroutines.yield
import org.koin.android.ext.android.inject
/**
* Created by Allan Wang on 2017-06-14.
@ -42,6 +45,8 @@ import kotlinx.coroutines.yield
*/
class NotificationService : BaseJobService() {
val cookieDao: CookieDao by inject()
override fun onStopJob(params: JobParameters?): Boolean {
super.onStopJob(params)
prepareFinish(true)
@ -81,7 +86,7 @@ class NotificationService : BaseJobService() {
private suspend fun sendNotifications(params: JobParameters?): Unit = withContext(Dispatchers.Default) {
val currentId = Prefs.userId
val cookies = loadFbCookiesSync()
val cookies = cookieDao.selectAll()
yield()
val jobId = params?.extras?.getInt(NOTIFICATION_PARAM_ID, -1) ?: -1
var notifCount = 0
@ -101,13 +106,16 @@ class NotificationService : BaseJobService() {
L.i { "Sent $notifCount notifications" }
if (notifCount == 0 && jobId == NOTIFICATION_JOB_NOW)
generalNotification(665, R.string.no_new_notifications, BuildConfig.DEBUG)
if (notifCount > 0) {
NotificationWidget.forceUpdate(this@NotificationService)
}
}
/**
* Implemented fetch to also notify when an error occurs
* Also normalized the output to return the number of notifications received
*/
private fun fetch(jobId: Int, type: NotificationType, cookie: CookieModel): Int {
private suspend fun fetch(jobId: Int, type: NotificationType, cookie: CookieEntity): Int {
val count = type.fetch(this, cookie)
if (count < 0) {
if (jobId == NOTIFICATION_JOB_NOW)

View File

@ -29,14 +29,15 @@ import ca.allanwang.kau.utils.string
import com.pitchedapps.frost.BuildConfig
import com.pitchedapps.frost.R
import com.pitchedapps.frost.activities.SettingsActivity
import com.pitchedapps.frost.dbflow.NotificationModel
import com.pitchedapps.frost.dbflow.loadFbCookiesAsync
import com.pitchedapps.frost.db.FrostDatabase
import com.pitchedapps.frost.db.deleteAll
import com.pitchedapps.frost.services.fetchNotifications
import com.pitchedapps.frost.services.scheduleNotifications
import com.pitchedapps.frost.utils.Prefs
import com.pitchedapps.frost.utils.frostSnackbar
import com.pitchedapps.frost.utils.materialDialogThemed
import com.pitchedapps.frost.views.Keywords
import kotlinx.coroutines.launch
/**
* Created by Allan Wang on 2017-06-29.
@ -171,8 +172,8 @@ fun SettingsActivity.getNotificationPrefs(): KPrefAdapterBuilder.() -> Unit = {
if (BuildConfig.DEBUG) {
plainText(R.string.reset_notif_epoch) {
onClick = {
loadFbCookiesAsync { cookies ->
cookies.map { NotificationModel(it.id) }.forEach { it.save() }
launch {
FrostDatabase.get().notifDao().deleteAll()
}
}
}

View File

@ -29,7 +29,7 @@ import ca.allanwang.kau.utils.showAppInfo
import ca.allanwang.kau.utils.string
import ca.allanwang.kau.utils.toast
import com.pitchedapps.frost.R
import com.pitchedapps.frost.dbflow.loadFbCookie
import com.pitchedapps.frost.db.CookieEntity
import com.pitchedapps.frost.facebook.USER_AGENT_DESKTOP
/**
@ -38,6 +38,7 @@ import com.pitchedapps.frost.facebook.USER_AGENT_DESKTOP
* With reference to <a href="https://stackoverflow.com/questions/33434532/android-webview-download-files-like-browsers-do">Stack Overflow</a>
*/
fun Context.frostDownload(
cookie: CookieEntity,
url: String?,
userAgent: String = USER_AGENT_DESKTOP,
contentDisposition: String? = null,
@ -45,10 +46,11 @@ fun Context.frostDownload(
contentLength: Long = 0L
) {
url ?: return
frostDownload(Uri.parse(url), userAgent, contentDisposition, mimeType, contentLength)
frostDownload(cookie, Uri.parse(url), userAgent, contentDisposition, mimeType, contentLength)
}
fun Context.frostDownload(
cookie: CookieEntity,
uri: Uri?,
userAgent: String = USER_AGENT_DESKTOP,
contentDisposition: String? = null,
@ -75,7 +77,6 @@ fun Context.frostDownload(
if (!granted) return@kauRequestPermissions
val request = DownloadManager.Request(uri)
request.setMimeType(mimeType)
val cookie = loadFbCookie(Prefs.userId) ?: return@kauRequestPermissions
val title = URLUtil.guessFileName(uri.toString(), contentDisposition, mimeType)
request.addRequestHeader("Cookie", cookie.cookie)
request.addRequestHeader("User-Agent", userAgent)

View File

@ -50,6 +50,11 @@ object L : KauLogger("Frost", {
d(message)
}
inline fun _e(e: Throwable?, message: () -> Any?) {
if (BuildConfig.DEBUG)
e(e, message)
}
override fun logImpl(priority: Int, message: String?, t: Throwable?) {
if (BuildConfig.DEBUG)
super.logImpl(priority, message, t)

View File

@ -0,0 +1,50 @@
/*
* Copyright 2019 Allan Wang
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package com.pitchedapps.frost.utils
import android.content.Context
import ca.allanwang.kau.utils.string
import com.pitchedapps.frost.R
import java.text.DateFormat
import java.text.SimpleDateFormat
import java.util.Calendar
import java.util.Date
import java.util.Locale
/**
* Converts time in millis to readable date,
* eg Apr 24 at 7:32 PM
*
* With regards to date modifications in calendars,
* it appears to respect calendar rules;
* see https://stackoverflow.com/a/43227817/4407321
*/
fun Long.toReadableTime(context: Context): String {
val cal = Calendar.getInstance()
cal.timeInMillis = this
val timeFormatter = SimpleDateFormat.getTimeInstance(DateFormat.SHORT)
val time = timeFormatter.format(Date(this))
val day = when {
cal >= Calendar.getInstance().apply { add(Calendar.DAY_OF_MONTH, -1) } -> context.string(R.string.today)
cal >= Calendar.getInstance().apply { add(Calendar.DAY_OF_MONTH, -2) } -> context.string(R.string.yesterday)
else -> {
val dayFormatter = SimpleDateFormat("MMM dd", Locale.getDefault())
dayFormatter.format(Date(this))
}
}
return context.getString(R.string.time_template, day, time)
}

View File

@ -62,7 +62,7 @@ import com.pitchedapps.frost.activities.TabCustomizerActivity
import com.pitchedapps.frost.activities.WebOverlayActivity
import com.pitchedapps.frost.activities.WebOverlayActivityBase
import com.pitchedapps.frost.activities.WebOverlayDesktopActivity
import com.pitchedapps.frost.dbflow.CookieModel
import com.pitchedapps.frost.db.CookieEntity
import com.pitchedapps.frost.facebook.FACEBOOK_COM
import com.pitchedapps.frost.facebook.FBCDN_NET
import com.pitchedapps.frost.facebook.FbCookie
@ -103,7 +103,7 @@ internal inline val Context.ctxCoroutine: CoroutineScope
get() = this as? CoroutineScope ?: GlobalScope
inline fun <reified T : Activity> Context.launchNewTask(
cookieList: ArrayList<CookieModel> = arrayListOf(),
cookieList: ArrayList<CookieEntity> = arrayListOf(),
clearStack: Boolean = false
) {
startActivity<T>(clearStack, intentBuilder = {
@ -111,13 +111,13 @@ inline fun <reified T : Activity> Context.launchNewTask(
})
}
fun Context.launchLogin(cookieList: ArrayList<CookieModel>, clearStack: Boolean = true) {
fun Context.launchLogin(cookieList: ArrayList<CookieEntity>, clearStack: Boolean = true) {
if (cookieList.isNotEmpty()) launchNewTask<SelectorActivity>(cookieList, clearStack)
else launchNewTask<LoginActivity>(clearStack = clearStack)
}
fun Activity.cookies(): ArrayList<CookieModel> {
return intent?.getParcelableArrayListExtra<CookieModel>(EXTRA_COOKIES) ?: arrayListOf()
fun Activity.cookies(): ArrayList<CookieEntity> {
return intent?.getParcelableArrayListExtra<CookieEntity>(EXTRA_COOKIES) ?: arrayListOf()
}
/**
@ -186,7 +186,7 @@ fun MaterialDialog.Builder.theme(): MaterialDialog.Builder {
}
fun Activity.setFrostTheme(forceTransparent: Boolean = false) {
val isTransparent = (Color.alpha(Prefs.bgColor) != 255) || forceTransparent
val isTransparent = (Color.alpha(Prefs.bgColor) != 255) || (Color.alpha(Prefs.headerColor) != 255) || forceTransparent
if (Prefs.bgColor.isColorDark)
setTheme(if (isTransparent) R.style.FrostTheme_Transparent else R.style.FrostTheme)
else

View File

@ -33,7 +33,7 @@ import com.bumptech.glide.request.RequestListener
import com.bumptech.glide.request.target.Target
import com.mikepenz.google_material_typeface_library.GoogleMaterial
import com.pitchedapps.frost.R
import com.pitchedapps.frost.dbflow.CookieModel
import com.pitchedapps.frost.db.CookieEntity
import com.pitchedapps.frost.facebook.profilePictureUrl
import com.pitchedapps.frost.glide.FrostGlide
import com.pitchedapps.frost.glide.GlideApp
@ -42,7 +42,7 @@ import com.pitchedapps.frost.utils.Prefs
/**
* Created by Allan Wang on 2017-06-05.
*/
class AccountItem(val cookie: CookieModel?) : KauIItem<AccountItem, AccountItem.ViewHolder>
class AccountItem(val cookie: CookieEntity?) : KauIItem<AccountItem, AccountItem.ViewHolder>
(R.layout.view_account, { ViewHolder(it) }, R.id.item_account) {
override fun bindView(viewHolder: ViewHolder, payloads: MutableList<Any>) {

View File

@ -32,6 +32,7 @@ import ca.allanwang.kau.utils.inflate
import ca.allanwang.kau.utils.isColorDark
import ca.allanwang.kau.utils.isGone
import ca.allanwang.kau.utils.isVisible
import ca.allanwang.kau.utils.launchMain
import ca.allanwang.kau.utils.setIcon
import ca.allanwang.kau.utils.setMenuIcons
import ca.allanwang.kau.utils.visible
@ -39,8 +40,11 @@ import ca.allanwang.kau.utils.withMinAlpha
import com.devbrackets.android.exomedia.listener.VideoControlsVisibilityListener
import com.mikepenz.google_material_typeface_library.GoogleMaterial
import com.pitchedapps.frost.R
import com.pitchedapps.frost.db.FrostDatabase
import com.pitchedapps.frost.db.currentCookie
import com.pitchedapps.frost.utils.L
import com.pitchedapps.frost.utils.Prefs
import com.pitchedapps.frost.utils.ctxCoroutine
import com.pitchedapps.frost.utils.frostDownload
import kotlinx.android.synthetic.main.view_video.view.*
@ -96,7 +100,10 @@ class FrostVideoViewer @JvmOverloads constructor(
video_toolbar.setOnMenuItemClickListener {
when (it.itemId) {
R.id.action_pip -> video.isExpanded = false
R.id.action_download -> context.frostDownload(video.videoUri)
R.id.action_download -> context.ctxCoroutine.launchMain {
val cookie = FrostDatabase.get().cookieDao().currentCookie() ?: return@launchMain
context.frostDownload(cookie, video.videoUri)
}
}
true
}

View File

@ -24,15 +24,19 @@ import android.util.AttributeSet
import android.view.View
import android.view.ViewGroup
import ca.allanwang.kau.utils.AnimHolder
import ca.allanwang.kau.utils.launchMain
import com.pitchedapps.frost.contracts.FrostContentContainer
import com.pitchedapps.frost.contracts.FrostContentCore
import com.pitchedapps.frost.contracts.FrostContentParent
import com.pitchedapps.frost.db.FrostDatabase
import com.pitchedapps.frost.db.currentCookie
import com.pitchedapps.frost.facebook.FB_HOME_URL
import com.pitchedapps.frost.facebook.FbItem
import com.pitchedapps.frost.facebook.USER_AGENT_DESKTOP
import com.pitchedapps.frost.facebook.USER_AGENT_MOBILE
import com.pitchedapps.frost.fragments.WebFragment
import com.pitchedapps.frost.utils.Prefs
import com.pitchedapps.frost.utils.ctxCoroutine
import com.pitchedapps.frost.utils.frostDownload
import com.pitchedapps.frost.web.FrostChromeClient
import com.pitchedapps.frost.web.FrostJSI
@ -81,7 +85,13 @@ class FrostWebView @JvmOverloads constructor(
webChromeClient = FrostChromeClient(this)
addJavascriptInterface(FrostJSI(this), "Frost")
setBackgroundColor(Color.TRANSPARENT)
setDownloadListener(context::frostDownload)
val db = FrostDatabase.get()
setDownloadListener { url, userAgent, contentDisposition, mimetype, contentLength ->
context.ctxCoroutine.launchMain {
val cookie = db.cookieDao().currentCookie() ?: return@launchMain
context.frostDownload(cookie, url, userAgent, contentDisposition, mimetype, contentLength)
}
}
return this
}

View File

@ -58,6 +58,7 @@ class DebugWebView @JvmOverloads constructor(
settings.userAgentString = USER_AGENT_MOBILE
setLayerType(View.LAYER_TYPE_HARDWARE, null)
webViewClient = DebugClient()
@Suppress("DEPRECATION")
isDrawingCacheEnabled = true
}
@ -72,6 +73,7 @@ class DebugWebView @JvmOverloads constructor(
}
try {
output.outputStream().use {
@Suppress("DEPRECATION")
drawingCache.compress(Bitmap.CompressFormat.PNG, 100, it)
}
L.d { "Created screenshot at ${output.absolutePath}" }

View File

@ -21,7 +21,7 @@ import android.webkit.JavascriptInterface
import com.pitchedapps.frost.activities.MainActivity
import com.pitchedapps.frost.contracts.MainActivityContract
import com.pitchedapps.frost.contracts.VideoViewHolder
import com.pitchedapps.frost.dbflow.CookieModel
import com.pitchedapps.frost.db.CookieEntity
import com.pitchedapps.frost.facebook.FbCookie
import com.pitchedapps.frost.utils.L
import com.pitchedapps.frost.utils.Prefs
@ -44,7 +44,7 @@ class FrostJSI(val web: FrostWebView) {
private val activity: MainActivity? = context as? MainActivity
private val header: SendChannel<String>? = activity?.headerBadgeChannel
private val refresh: SendChannel<Boolean> = web.parent.refreshChannel
private val cookies: List<CookieModel> = activity?.cookies() ?: arrayListOf()
private val cookies: List<CookieEntity> = activity?.cookies() ?: arrayListOf()
/**
* Attempts to load the url in an overlay

View File

@ -29,7 +29,7 @@ import android.webkit.WebView
import ca.allanwang.kau.utils.fadeIn
import ca.allanwang.kau.utils.isVisible
import ca.allanwang.kau.utils.launchMain
import com.pitchedapps.frost.dbflow.CookieModel
import com.pitchedapps.frost.db.CookieEntity
import com.pitchedapps.frost.facebook.FB_LOGIN_URL
import com.pitchedapps.frost.facebook.FB_USER_MATCHER
import com.pitchedapps.frost.facebook.FbCookie
@ -51,7 +51,7 @@ class LoginWebView @JvmOverloads constructor(
defStyleAttr: Int = 0
) : WebView(context, attrs, defStyleAttr) {
private val completable: CompletableDeferred<CookieModel> = CompletableDeferred()
private val completable: CompletableDeferred<CookieEntity> = CompletableDeferred()
private lateinit var progressCallback: (Int) -> Unit
@SuppressLint("SetJavaScriptEnabled")
@ -62,7 +62,7 @@ class LoginWebView @JvmOverloads constructor(
webChromeClient = LoginChromeClient()
}
suspend fun loadLogin(progressCallback: (Int) -> Unit): CompletableDeferred<CookieModel> = coroutineScope {
suspend fun loadLogin(progressCallback: (Int) -> Unit): CompletableDeferred<CookieEntity> = coroutineScope {
this@LoginWebView.progressCallback = progressCallback
L.d { "Begin loading login" }
launchMain {
@ -77,18 +77,18 @@ class LoginWebView @JvmOverloads constructor(
override fun onPageFinished(view: WebView, url: String?) {
super.onPageFinished(view, url)
val cookieModel = checkForLogin(url)
if (cookieModel != null)
completable.complete(cookieModel)
val cookie = checkForLogin(url)
if (cookie != null)
completable.complete(cookie)
if (!view.isVisible) view.fadeIn()
}
fun checkForLogin(url: String?): CookieModel? {
fun checkForLogin(url: String?): CookieEntity? {
if (!url.isFacebookUrl) return null
val cookie = CookieManager.getInstance().getCookie(url) ?: return null
L.d { "Checking cookie for login" }
val id = FB_USER_MATCHER.find(cookie)[1]?.toLong() ?: return null
return CookieModel(id, "", cookie)
return CookieEntity(id, null, cookie)
}
override fun onPageCommitVisible(view: WebView, url: String?) {

View File

@ -0,0 +1,194 @@
/*
* Copyright 2019 Allan Wang
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package com.pitchedapps.frost.widgets
import android.app.PendingIntent
import android.appwidget.AppWidgetManager
import android.appwidget.AppWidgetProvider
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.graphics.Canvas
import android.graphics.Paint
import android.graphics.PorterDuff
import android.graphics.PorterDuffColorFilter
import android.graphics.drawable.Icon
import android.os.Build
import android.widget.RemoteViews
import android.widget.RemoteViewsService
import androidx.annotation.ColorInt
import androidx.annotation.DrawableRes
import androidx.annotation.IdRes
import ca.allanwang.kau.utils.dimenPixelSize
import ca.allanwang.kau.utils.withAlpha
import com.pitchedapps.frost.R
import com.pitchedapps.frost.activities.MainActivity
import com.pitchedapps.frost.db.NotificationDao
import com.pitchedapps.frost.db.selectNotificationsSync
import com.pitchedapps.frost.glide.FrostGlide
import com.pitchedapps.frost.glide.GlideApp
import com.pitchedapps.frost.services.NotificationContent
import com.pitchedapps.frost.services.NotificationType
import com.pitchedapps.frost.utils.Prefs
import com.pitchedapps.frost.utils.toReadableTime
import org.koin.standalone.KoinComponent
import org.koin.standalone.inject
class NotificationWidget : AppWidgetProvider() {
override fun onUpdate(context: Context, appWidgetManager: AppWidgetManager, appWidgetIds: IntArray) {
super.onUpdate(context, appWidgetManager, appWidgetIds)
val type = NotificationType.GENERAL
val userId = Prefs.userId
val intent = NotificationWidgetService.createIntent(context, type, userId)
for (id in appWidgetIds) {
val views = RemoteViews(context.packageName, R.layout.widget_notifications)
views.setBackgroundColor(R.id.widget_layout_toolbar, Prefs.headerColor)
views.setIcon(R.id.img_frost, context, R.drawable.frost_f_24, Prefs.iconColor)
views.setOnClickPendingIntent(
R.id.img_frost,
PendingIntent.getActivity(context, 0, Intent(context, MainActivity::class.java), 0)
)
views.setBackgroundColor(R.id.widget_notification_list, Prefs.bgColor)
views.setRemoteAdapter(R.id.widget_notification_list, intent)
val pendingIntentTemplate = PendingIntent.getActivity(
context,
0,
type.createCommonIntent(context, userId),
PendingIntent.FLAG_UPDATE_CURRENT
)
views.setPendingIntentTemplate(R.id.widget_notification_list, pendingIntentTemplate)
appWidgetManager.updateAppWidget(id, views)
}
appWidgetManager.notifyAppWidgetViewDataChanged(appWidgetIds, R.id.widget_notification_list)
}
companion object {
fun forceUpdate(context: Context) {
val manager = AppWidgetManager.getInstance(context)
val ids = manager.getAppWidgetIds(ComponentName(context, NotificationWidget::class.java))
val intent = Intent().apply {
action = AppWidgetManager.ACTION_APPWIDGET_UPDATE
putExtra(AppWidgetManager.EXTRA_APPWIDGET_IDS, ids)
}
context.sendBroadcast(intent)
}
}
}
private const val NOTIF_WIDGET_TYPE = "notif_widget_type"
private const val NOTIF_WIDGET_USER_ID = "notif_widget_user_id"
private fun RemoteViews.setBackgroundColor(@IdRes viewId: Int, @ColorInt color: Int) {
setInt(viewId, "setBackgroundColor", color)
}
/**
* Adds backward compatibility to setting tinted icons
*/
private fun RemoteViews.setIcon(@IdRes viewId: Int, context: Context, @DrawableRes res: Int, @ColorInt color: Int) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val icon = Icon.createWithResource(context, res).setTint(color).setTintMode(PorterDuff.Mode.SRC_IN)
setImageViewIcon(viewId, icon)
} else {
val bitmap = BitmapFactory.decodeResource(context.resources, res)
if (bitmap != null) {
val paint = Paint()
paint.colorFilter = PorterDuffColorFilter(color, PorterDuff.Mode.SRC_IN)
val result = Bitmap.createBitmap(bitmap.width, bitmap.height, Bitmap.Config.ARGB_8888)
val canvas = Canvas(result)
canvas.drawBitmap(bitmap, 0f, 0f, paint)
setImageViewBitmap(viewId, result)
} else {
// Fallback to just icon
setImageViewResource(viewId, res)
}
}
}
class NotificationWidgetService : RemoteViewsService() {
override fun onGetViewFactory(intent: Intent): RemoteViewsFactory = NotificationWidgetDataProvider(this, intent)
companion object {
fun createIntent(context: Context, type: NotificationType, userId: Long): Intent =
Intent(context, NotificationWidgetService::class.java)
.putExtra(NOTIF_WIDGET_TYPE, type.name)
.putExtra(NOTIF_WIDGET_USER_ID, userId)
}
}
class NotificationWidgetDataProvider(val context: Context, val intent: Intent) : RemoteViewsService.RemoteViewsFactory,
KoinComponent {
private val notifDao: NotificationDao by inject()
@Volatile
private var content: List<NotificationContent> = emptyList()
private val type = NotificationType.valueOf(intent.getStringExtra(NOTIF_WIDGET_TYPE))
private val userId = intent.getLongExtra(NOTIF_WIDGET_USER_ID, -1)
private val avatarSize = context.dimenPixelSize(R.dimen.avatar_image_size)
private val glide = GlideApp.with(context).asBitmap()
private fun loadNotifications() {
content = notifDao.selectNotificationsSync(userId, type.channelId)
}
override fun onCreate() {
}
override fun onDataSetChanged() {
loadNotifications()
}
override fun getLoadingView(): RemoteViews? = null
override fun getItemId(position: Int): Long = content[position].id
override fun hasStableIds(): Boolean = true
override fun getViewAt(position: Int): RemoteViews {
val views = RemoteViews(context.packageName, R.layout.widget_notification_item)
val notif = content[position]
views.setBackgroundColor(R.id.item_frame, Prefs.nativeBgColor(notif.unread))
views.setTextColor(R.id.item_content, Prefs.textColor)
views.setTextViewText(R.id.item_content, notif.text)
views.setTextColor(R.id.item_date, Prefs.textColor.withAlpha(150))
views.setTextViewText(R.id.item_date, notif.timestamp.toReadableTime(context))
val avatar = glide.load(notif.profileUrl).transform(FrostGlide.circleCrop).submit(avatarSize, avatarSize).get()
views.setImageViewBitmap(R.id.item_avatar, avatar)
views.setOnClickFillInIntent(R.id.item_frame, type.putContentExtra(Intent(), notif))
return views
}
override fun getCount(): Int = content.size
override fun getViewTypeCount(): Int = 1
override fun onDestroy() {
}
}

View File

@ -0,0 +1,48 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="30dp"
android:height="40dp"
android:viewportWidth="300"
android:viewportHeight="400">
<path
android:pathData="M0,0h300v400H0V0z"
android:fillColor="#fafafa"/>
<path
android:pathData="M0,0h300v50H0V0z"
android:fillColor="@color/facebook_blue"/>
<path
android:pathData="M65,170a20,20 0,1 1,-40 0,20 20,0 0,1 40,0z"
android:fillColor="#DE000000"/>
<path
android:pathData="M85,150h184v11H85v-11z"
android:fillColor="#DE000000"/>
<path
android:pathData="M85,179h146v11H85v-11z"
android:fillColor="#DE000000"/>
<path
android:pathData="M65,95a20,20 0,1 1,-40 0,20 20,0 0,1 40,0z"
android:fillColor="#DE000000"/>
<path
android:pathData="M85,75h184v11H85V75z"
android:fillColor="#DE000000"/>
<path
android:pathData="M85,104h146v11H85v-11z"
android:fillColor="#DE000000"/>
<path
android:pathData="M65,245a20,20 0,1 1,-40 0,20 20,0 0,1 40,0z"
android:fillColor="#DE000000"/>
<path
android:pathData="M85,225h184v11H85v-11z"
android:fillColor="#DE000000"/>
<path
android:pathData="M85,254h146v11H85v-11z"
android:fillColor="#DE000000"/>
<path
android:pathData="M65,320a20,20 0,1 1,-40 0,20 20,0 0,1 40,0z"
android:fillColor="#DE000000"/>
<path
android:pathData="M85,300h184v11H85v-11z"
android:fillColor="#DE000000"/>
<path
android:pathData="M85,329h146v11H85v-11z"
android:fillColor="#DE000000"/>
</vector>

View File

@ -0,0 +1,46 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/item_frame"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="?android:selectableItemBackground"
android:orientation="horizontal"
android:paddingStart="@dimen/kau_activity_horizontal_margin"
android:paddingTop="@dimen/kau_activity_vertical_margin"
android:paddingEnd="@dimen/kau_activity_horizontal_margin"
android:paddingBottom="@dimen/kau_activity_vertical_margin">
<ImageView
android:id="@+id/item_avatar"
android:layout_width="@dimen/avatar_image_size"
android:layout_height="@dimen/avatar_image_size" />
<!--
Unlike the actual notification panel,
we do not show thumbnails, and we limit the title length
-->
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/kau_padding_normal"
android:layout_weight="1"
android:orientation="vertical">
<TextView
android:id="@+id/item_content"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:ellipsize="end"
android:lines="2" />
<TextView
android:id="@+id/item_date"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:ellipsize="end"
android:lines="1"
android:textSize="12sp" />
</LinearLayout>
</LinearLayout>

View File

@ -0,0 +1,31 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/widget_layout_container"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<LinearLayout
android:id="@+id/widget_layout_toolbar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:paddingStart="@dimen/kau_padding_small"
android:paddingEnd="@dimen/kau_padding_small">
<ImageView
android:id="@+id/img_frost"
android:layout_width="@dimen/toolbar_icon_size"
android:layout_height="@dimen/toolbar_icon_size"
android:layout_gravity="center_vertical"
android:layout_margin="@dimen/kau_padding_small"
android:background="?android:selectableItemBackgroundBorderless" />
</LinearLayout>
<ListView
android:id="@+id/widget_notification_list"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1" />
</LinearLayout>

View File

@ -46,4 +46,12 @@ plně funkční náhrada za oficiální aplikaci Facebooku, vytvořena od nuly a
<string name="options">Nastavení</string>
<string name="tab_customizer_instructions">Dlouhým stiskem přeuspořádejte horní ikony.</string>
<string name="no_new_notifications">Žádné nové oznámení</string>
<!--
Template used to display human readable string;
For instance:
Today at 1:23 PM
Mar 13 at 9:00 AM
The first element is the day, and the second element is the time
-->
</resources>

View File

@ -45,4 +45,12 @@
<string name="options">Valgmuligheder</string>
<string name="tab_customizer_instructions">Hold nede og træk for at flytte de øverste ikoner.</string>
<string name="no_new_notifications">Ingen nye notifikationer</string>
<!--
Template used to display human readable string;
For instance:
Today at 1:23 PM
Mar 13 at 9:00 AM
The first element is the day, and the second element is the time
-->
</resources>

View File

@ -17,6 +17,7 @@
<string name="birthdays">Geburtstage</string>
<string name="chat">Chat</string>
<string name="photos">Fotos</string>
<string name="marketplace">Marktplatz</string>
<string name="notes">Notizen</string>
<string name="on_this_day">An diesem Tag</string>
<string name="loading_account">Alles wird vorbereitet…</string>
@ -45,4 +46,12 @@
<string name="options">Optionen</string>
<string name="tab_customizer_instructions">Durch langes Drücken und Ziehen können Sie die oberen Symbole neu anordnen.</string>
<string name="no_new_notifications">Keine neue Benachrichtigungen gefunden</string>
<!--
Template used to display human readable string;
For instance:
Today at 1:23 PM
Mar 13 at 9:00 AM
The first element is the day, and the second element is the time
-->
</resources>

View File

@ -11,6 +11,8 @@
<string name="suggested_friends_desc">Zeige \"Leute die du vielleicht kennst\" im Feed</string>
<string name="suggested_groups">Empfohlene Gruppen</string>
<string name="suggested_groups_desc">Zeige \"Empfohlene Gruppen\" im Feed</string>
<string name="show_stories">Story\'s anzeigen</string>
<string name="show_stories_desc">Story\'s in den Feed anzeigen</string>
<string name="facebook_ads">Facebook Werbung</string>
<string name="facebook_ads_desc">Zeige native Facebook Werbung</string>
</resources>

View File

@ -17,6 +17,7 @@
<string name="birthdays">Cumpleaños</string>
<string name="chat">Chat</string>
<string name="photos">Fotos</string>
<string name="marketplace">Marketplace</string>
<string name="notes">Notas</string>
<string name="on_this_day">En este día</string>
<string name="loading_account">Preparando todo…</string>
@ -45,4 +46,14 @@
<string name="options">Opciones</string>
<string name="tab_customizer_instructions">Mantén pulsado y arrastra para reorganizar los iconos superiores.</string>
<string name="no_new_notifications">No se han encontrado Notificaciones</string>
<string name="today">Hoy</string>
<string name="yesterday">Ayer</string>
<!--
Template used to display human readable string;
For instance:
Today at 1:23 PM
Mar 13 at 9:00 AM
The first element is the day, and the second element is the time
-->
</resources>

View File

@ -17,6 +17,8 @@
<string name="force_message_bottom_desc">Al cargar un hilo de mensaje, activa un desplazamiento hacia la parte inferior de la página en lugar de cargar la página tal como es.</string>
<string name="enable_pip">Activar PIP</string>
<string name="enable_pip_desc">Activar función de video en miniatura</string>
<string name="autoplay_settings">Configuración de jugadas automáticas</string>
<string name="autoplay_settings_desc">Abra configuración de juego de auto de Facebook. Tenga en cuenta que debe estar desactivada para que PIP trabajar.</string>
<string name="exit_confirmation">Confirmar salida</string>
<string name="exit_confirmation_desc">Muestra un diálogo de confirmación antes de salir de la app</string>
<string name="analytics">Analytics</string>

View File

@ -11,6 +11,8 @@
<string name="suggested_friends_desc">Mostrar \"Gente que quizá conozcas\" en el feed</string>
<string name="suggested_groups">Grupos sugeridos</string>
<string name="suggested_groups_desc">Mostrar \"grupos sugeridos\" en el feed</string>
<string name="show_stories">Historias destacadas</string>
<string name="show_stories_desc">Mostrar historias en el feed</string>
<string name="facebook_ads">Anuncios de Facebook</string>
<string name="facebook_ads_desc">Mostrar anuncios nativos de Facebook</string>
</resources>

View File

@ -1,15 +0,0 @@
<?xml version="1.0" encoding="utf-8"?><!--Generated by crowdin.com-->
<resources>
<string name="newsfeed_sort">Orden de las Publicaciones</string>
<string name="newsfeed_sort_desc">Define el orden en que aparecen las publicaciones</string>
<string name="aggressive_recents">Modo \"Más Recientes\" agresivo</string>
<string name="aggressive_recents_desc">Filtra de manera adicional las publicaciones más antiguas de Facebook de las noticias recientes. Deshabilita esta opción si el feed se encuentra vacio.</string>
<string name="composer">Escritor de Estado</string>
<string name="composer_desc">Mostrar escritor de estado en el feed</string>
<string name="suggested_friends">Sugerencias de Amigos</string>
<string name="suggested_friends_desc">Mostrar \"Gente que quizá conozcas\" en el feed</string>
<string name="suggested_groups">Grupos sugeridos</string>
<string name="suggested_groups_desc">Mostrar \"grupos sugeridos\" en el feed</string>
<string name="facebook_ads">Publicidad de Facebook</string>
<string name="facebook_ads_desc">Mostrar Publicidad Nativa de Facebook</string>
</resources>

View File

@ -17,6 +17,7 @@
<string name="birthdays">Anniversaires</string>
<string name="chat">Conversations</string>
<string name="photos">Photos</string>
<string name="marketplace">Marketplace</string>
<string name="notes">Notes</string>
<string name="on_this_day">Aujourd\'hui</string>
<string name="loading_account">Tout se prépare…</string>
@ -45,4 +46,15 @@
<string name="options">Options</string>
<string name="tab_customizer_instructions">Appuyez longuement et faites glisser pour réorganiser les icônes du haut.</string>
<string name="no_new_notifications">Pas de nouvelles notifications trouvées</string>
<string name="today">Aujourd\'hui</string>
<string name="yesterday">Hier</string>
<!--
Template used to display human readable string;
For instance:
Today at 1:23 PM
Mar 13 at 9:00 AM
The first element is the day, and the second element is the time
-->
<string name="time_template">%1s à %2s</string>
</resources>

View File

@ -17,6 +17,8 @@
<string name="force_message_bottom_desc">Lors du chargement dun fil de message, déclencher un défilement vers le bas de la page au lieu de charger la page telle quelle.</string>
<string name="enable_pip">Activer le PIP</string>
<string name="enable_pip_desc">Activer les vidéos Picture In Picture</string>
<string name="autoplay_settings">Paramètres de lecture automatique</string>
<string name="autoplay_settings_desc">Ouvrir les paramètres de lecture automatique de Facebook. Notez qu\'il doit être désactivé pour que PIP fonctionne.</string>
<string name="exit_confirmation">Confirmation de la sortie</string>
<string name="exit_confirmation_desc">Afficher la boîte de dialogue de confirmation avant de quitter lapplication</string>
<string name="analytics">Analytics</string>

View File

@ -4,13 +4,15 @@
<string name="newsfeed_sort">Ordre du fil d\'actualité</string>
<string name="newsfeed_sort_desc">Définit lordre dans lequel les messages sont affichés</string>
<string name="aggressive_recents">Récents agressifs</string>
<string name="aggressive_recents_desc">Filtrer les vieilles publications additionnelles du fil d\'actualité les plus récentes de Facebook. Désactivez cette option si votre fil d\'actualités est vide.</string>
<string name="aggressive_recents_desc">Éliminer les anciennes publications additionnelles du fil d\'actualité récentes de Facebook. Désactivez cette option si votre fil d\'actualités est vide.</string>
<string name="composer">Compositeur de statut</string>
<string name="composer_desc">Montrer le compositeur de statut dans le fil d\'actualité</string>
<string name="suggested_friends">Amis suggérés</string>
<string name="suggested_friends_desc">Afficher les «Personnes que vous pouvez connaître» dans le fil d\'actualité</string>
<string name="suggested_groups">Groupes Suggérés</string>
<string name="suggested_groups_desc">Afficher les «Groupes Suggérés» dans le fil d\'actualité</string>
<string name="show_stories">Montrer les Top Stories</string>
<string name="show_stories_desc">Montrer les stories dans le fil d\'actualité</string>
<string name="facebook_ads">Publicités Facebook</string>
<string name="facebook_ads_desc">Afficher les publicités Facebook</string>
</resources>

View File

@ -48,4 +48,12 @@
<string name="options">Opcións</string>
<string name="tab_customizer_instructions">Toque longo e arrastra para reorganizar as iconas superiores.</string>
<string name="no_new_notifications">Non se atopou ningunha nova notificación</string>
<!--
Template used to display human readable string;
For instance:
Today at 1:23 PM
Mar 13 at 9:00 AM
The first element is the day, and the second element is the time
-->
</resources>

View File

@ -17,6 +17,7 @@
<string name="birthdays">Születésnapok</string>
<string name="chat">Chat</string>
<string name="photos">Fényképek</string>
<string name="marketplace">Piactér</string>
<string name="notes">Jegyzetek</string>
<string name="on_this_day">Ezen a napon</string>
<string name="loading_account">Előkészítés…</string>
@ -45,4 +46,12 @@
<string name="options">Beállítások</string>
<string name="tab_customizer_instructions">Tartsd nyomva és húzd a felső ikonokat az átrendezéshez.</string>
<string name="no_new_notifications">Nem találhatók új értesítések</string>
<!--
Template used to display human readable string;
For instance:
Today at 1:23 PM
Mar 13 at 9:00 AM
The first element is the day, and the second element is the time
-->
</resources>

View File

@ -11,6 +11,8 @@
<string name="suggested_friends_desc">\"Emberek, akiket ismerhetsz\" megjelenítése a hírcsatornában</string>
<string name="suggested_groups">Javasolt csoportok</string>
<string name="suggested_groups_desc">\"Javasolt csoportok\" megjelenítése a hírcsatornában</string>
<string name="show_stories">Történetek megjelenítése</string>
<string name="show_stories_desc">Történetek megjelenítése a hírfolyamban</string>
<string name="facebook_ads">Facebook hirdetések</string>
<string name="facebook_ads_desc">Natív Facebook-hirdetések megjelenítése</string>
</resources>

View File

@ -45,4 +45,12 @@
<string name="preview">Pratinjau</string>
<string name="options">Pilihan</string>
<string name="tab_customizer_instructions">Tekan lama dan tarik untuk mengatur ulang ikon atas.</string>
<!--
Template used to display human readable string;
For instance:
Today at 1:23 PM
Mar 13 at 9:00 AM
The first element is the day, and the second element is the time
-->
</resources>

View File

@ -46,4 +46,12 @@
<string name="options">Opzioni</string>
<string name="tab_customizer_instructions">Per riordinare un\'icona tienila premuta e trascinala.</string>
<string name="no_new_notifications">Nessuna nuova notifica trovata</string>
<!--
Template used to display human readable string;
For instance:
Today at 1:23 PM
Mar 13 at 9:00 AM
The first element is the day, and the second element is the time
-->
</resources>

View File

@ -1,15 +0,0 @@
<?xml version="1.0" encoding="utf-8"?><!--Generated by crowdin.com-->
<resources>
<string name="newsfeed_sort">Ordine della Sezione Notizie</string>
<string name="newsfeed_sort_desc">Definisce l\'ordine in cui sono mostrati i post</string>
<string name="aggressive_recents">Recenti Aggressivi</string>
<string name="aggressive_recents_desc">Filtra ulteriori post vecchi dalla sezione originale più recenti di Facebook. Disabilita se il tuo feed è vuoto.</string>
<string name="composer">Compositore di Stato</string>
<string name="composer_desc">Mostra la casella per comporre uno stato nelle Notizie</string>
<string name="suggested_friends">Amici Suggeriti</string>
<string name="suggested_friends_desc">Mostra \"Persone Che Potresti Conoscere\" nel feed</string>
<string name="suggested_groups">Gruppi Suggeriti</string>
<string name="suggested_groups_desc">Mostra \"Gruppi Suggeriti\" nel feed</string>
<string name="facebook_ads">Pubblicità Facebook</string>
<string name="facebook_ads_desc">Mostra le pubblicità native di Facebook</string>
</resources>

View File

@ -42,4 +42,12 @@
<string name="file_chooser_not_found">파일 선택기를 찾을 수 없습니다.</string>
<string name="top_bar">상단 바</string>
<string name="bottom_bar">하단 바</string>
<!--
Template used to display human readable string;
For instance:
Today at 1:23 PM
Mar 13 at 9:00 AM
The first element is the day, and the second element is the time
-->
</resources>

View File

@ -46,4 +46,12 @@
<string name="options">Opties</string>
<string name="tab_customizer_instructions">Klik en houd vast om de iconen in de gewenste volgorde te slepen.</string>
<string name="no_new_notifications">Geen nieuwe notificaties</string>
<!--
Template used to display human readable string;
For instance:
Today at 1:23 PM
Mar 13 at 9:00 AM
The first element is the day, and the second element is the time
-->
</resources>

View File

@ -44,4 +44,12 @@
<string name="preview">Forhåndsvisning</string>
<string name="options">Alternativer</string>
<string name="tab_customizer_instructions">Langt trykk og dra for å endre topp ikonene.</string>
<!--
Template used to display human readable string;
For instance:
Today at 1:23 PM
Mar 13 at 9:00 AM
The first element is the day, and the second element is the time
-->
</resources>

View File

@ -45,4 +45,12 @@
<string name="options">Opcje</string>
<string name="tab_customizer_instructions">Długie naciśnięcie i przeciągnięcie, aby zmienić kolejność ikon.</string>
<string name="no_new_notifications">Brak nowych powiadomień</string>
<!--
Template used to display human readable string;
For instance:
Today at 1:23 PM
Mar 13 at 9:00 AM
The first element is the day, and the second element is the time
-->
</resources>

View File

@ -17,6 +17,7 @@
<string name="birthdays">Aniversários</string>
<string name="chat">Amigos online</string>
<string name="photos">Fotos</string>
<string name="marketplace">Marketplace</string>
<string name="notes">Notas</string>
<string name="on_this_day">Neste Dia</string>
<string name="loading_account">Preparando tudo…</string>
@ -46,4 +47,15 @@
<string name="options">Opções</string>
<string name="tab_customizer_instructions">Mantenha pressionado e arraste para reorganizar os ícones superiores.</string>
<string name="no_new_notifications">Nenhuma nova notificação encontrada</string>
<string name="today">Hoje</string>
<string name="yesterday">Ontem</string>
<!--
Template used to display human readable string;
For instance:
Today at 1:23 PM
Mar 13 at 9:00 AM
The first element is the day, and the second element is the time
-->
<string name="time_template">%1s às %2s</string>
</resources>

View File

@ -17,6 +17,8 @@
<string name="force_message_bottom_desc">Ao carregar um tópico de mensagem, aciona uma rolagem para a parte inferior da página em vez de carregar a página como está.</string>
<string name="enable_pip">Habilitar o PIP</string>
<string name="enable_pip_desc">Habilita o Picture in Picture (janelas flutuantes de vídeos)</string>
<string name="autoplay_settings">Configurações de reprodução automática</string>
<string name="autoplay_settings_desc">Abra as configurações de reprodução automática do Facebook. Observe que ele deve ser desativado para que o PIP funcione.</string>
<string name="exit_confirmation">Confirmação de Saída</string>
<string name="exit_confirmation_desc">Mostrar caixa de diálogo de confirmação antes de sair do aplicativo</string>
<string name="analytics">Telemetria</string>

View File

@ -11,6 +11,8 @@
<string name="suggested_friends_desc">Mostra \"Pessoas Que Talvez Você Conheça\" no Feed</string>
<string name="suggested_groups">Grupos Sugeridos</string>
<string name="suggested_groups_desc">Mostra \"Grupos Sugeridos\" no Feed</string>
<string name="show_stories">Mostrar Histórias</string>
<string name="show_stories_desc">Mostrar histórias no feed</string>
<string name="facebook_ads">Anúncios do Facebook</string>
<string name="facebook_ads_desc">Mostrar anúncios nativos do Facebook</string>
</resources>

View File

@ -45,4 +45,12 @@
<string name="options">Opções</string>
<string name="tab_customizer_instructions">Toque longo e arraste para dispor os ícones superiores.</string>
<string name="no_new_notifications">Sem notificações novas</string>
<!--
Template used to display human readable string;
For instance:
Today at 1:23 PM
Mar 13 at 9:00 AM
The first element is the day, and the second element is the time
-->
</resources>

View File

@ -46,4 +46,12 @@
<string name="options">Opțiuni</string>
<string name="tab_customizer_instructions">Apasă lung și trage să rearanjezi.</string>
<string name="no_new_notifications">Nu s-au găsit notificări</string>
<!--
Template used to display human readable string;
For instance:
Today at 1:23 PM
Mar 13 at 9:00 AM
The first element is the day, and the second element is the time
-->
</resources>

View File

@ -17,6 +17,7 @@
<string name="birthdays">Дни рождения</string>
<string name="chat">Написать</string>
<string name="photos">Фотографии</string>
<string name="marketplace">Marketplace</string>
<string name="notes">Заметки</string>
<string name="on_this_day">В этот день</string>
<string name="loading_account">Почти готово…</string>
@ -45,4 +46,14 @@
<string name="options">Опции</string>
<string name="tab_customizer_instructions">Долго нажмите и перетащите чтобы переставить иконки</string>
<string name="no_new_notifications">Новые уведомления отсутствуют</string>
<string name="today">Сегодня</string>
<string name="yesterday">Вчера</string>
<!--
Template used to display human readable string;
For instance:
Today at 1:23 PM
Mar 13 at 9:00 AM
The first element is the day, and the second element is the time
-->
</resources>

View File

@ -11,6 +11,8 @@
<string name="suggested_friends_desc">Смотреть «Люди которых вы можете знать» в канале</string>
<string name="suggested_groups">Предлагаемые группы</string>
<string name="suggested_groups_desc">Смотреть «Предложения групп» в канале</string>
<string name="show_stories">Показывать Истории</string>
<string name="show_stories_desc">Показывать Истории в ленте</string>
<string name="facebook_ads">- Реклама в Facebook</string>
<string name="facebook_ads_desc">Показать родной Facebook объявления</string>
</resources>

View File

@ -45,4 +45,12 @@
<string name="options">Опције</string>
<string name="tab_customizer_instructions">Задржите и превуците да би прерасподелили горње иконице.</string>
<string name="no_new_notifications">Нема нових обавештења</string>
<!--
Template used to display human readable string;
For instance:
Today at 1:23 PM
Mar 13 at 9:00 AM
The first element is the day, and the second element is the time
-->
</resources>

View File

@ -46,4 +46,12 @@
<string name="options">Inställningar</string>
<string name="tab_customizer_instructions">Tryck och håll kvar för att arrangera om topp-ikonerna.</string>
<string name="no_new_notifications">Inga nya notifikationer hittades</string>
<!--
Template used to display human readable string;
For instance:
Today at 1:23 PM
Mar 13 at 9:00 AM
The first element is the day, and the second element is the time
-->
</resources>

View File

@ -44,4 +44,12 @@
<string name="preview">แสดงตัวอย่าง</string>
<string name="options">ตัวเลือก</string>
<string name="tab_customizer_instructions">กดค้างและลากเพื่อจัดเรียงไอคอนด้านบน</string>
<!--
Template used to display human readable string;
For instance:
Today at 1:23 PM
Mar 13 at 9:00 AM
The first element is the day, and the second element is the time
-->
</resources>

View File

@ -45,4 +45,12 @@
<string name="preview">Pribyu</string>
<string name="options">Ang mga opsyon</string>
<string name="tab_customizer_instructions">Pindutin ng matagal at hilahin para mabago ang ayos ng pangunahing imahe.</string>
<!--
Template used to display human readable string;
For instance:
Today at 1:23 PM
Mar 13 at 9:00 AM
The first element is the day, and the second element is the time
-->
</resources>

View File

@ -17,6 +17,7 @@
<string name="birthdays">Doğum Günleri</string>
<string name="chat">Sohbet</string>
<string name="photos">Fotoğraflar</string>
<string name="marketplace">Pazar yeri</string>
<string name="notes">Notlar</string>
<string name="on_this_day">Bu günde</string>
<string name="loading_account">Her şey hazır alınıyor…</string>
@ -45,4 +46,14 @@
<string name="options">Seçenekler</string>
<string name="tab_customizer_instructions">Üstteki simgeleri yeniden düzenlemek için uzun basın ve sonra sürükleyin.</string>
<string name="no_new_notifications">Yeni bildirim bulunmadı</string>
<string name="today">Bugün</string>
<string name="yesterday">Dün</string>
<!--
Template used to display human readable string;
For instance:
Today at 1:23 PM
Mar 13 at 9:00 AM
The first element is the day, and the second element is the time
-->
</resources>

View File

@ -21,6 +21,7 @@
<string name="force_message_bottom_desc">Birileti dizisi yüklerken, sayfayı olduğu gibi yüklemek yerine, sayfanın altına kaydırma yapın.</string>
<string name="enable_pip">PIP\'i etkinleştir</string>
<string name="enable_pip_desc">PIP (Picture in Picture) videolarını etkinleştir</string>
<string name="autoplay_settings">Otomatik oynatma ayarları</string>
<string name="exit_confirmation">Çıkış Onayı</string>
<string name="exit_confirmation_desc">Uygulamadan çıkmadanönce onay iletişim kutusunu göster</string>
<string name="analytics">Analiz</string>

View File

@ -11,6 +11,8 @@
<string name="suggested_friends_desc">Özet akışında \"Tanıdığınız İnsanları\" gösterin</string>
<string name="suggested_groups">Önerilen gruplar</string>
<string name="suggested_groups_desc">Özet akışında \"önerilen grup\" ları göster</string>
<string name="show_stories">Hikayeleri göster</string>
<string name="show_stories_desc">Akışda ki hikayeleri göster</string>
<string name="facebook_ads">Facebook reklamları</string>
<string name="facebook_ads_desc">Yerli Facebook reklamlarını göster</string>
</resources>

View File

@ -17,6 +17,7 @@
<string name="birthdays">Дні народження</string>
<string name="chat">Чат</string>
<string name="photos">Фотографії</string>
<string name="marketplace">Магазин</string>
<string name="notes">Замітки</string>
<string name="on_this_day">Цього дня</string>
<string name="loading_account">Отримання всього готове…</string>
@ -45,4 +46,12 @@
<string name="options">Опції</string>
<string name="tab_customizer_instructions">Довге натискання та перетягніть, щоб переставити верхній значок.</string>
<string name="no_new_notifications">Нових повідомлень не знайдено</string>
<!--
Template used to display human readable string;
For instance:
Today at 1:23 PM
Mar 13 at 9:00 AM
The first element is the day, and the second element is the time
-->
</resources>

View File

@ -11,6 +11,8 @@
<string name="suggested_friends_desc">Показати \"Люди, яких ви можете знати\" у новинній стрічці</string>
<string name="suggested_groups">Пропоновані групи</string>
<string name="suggested_groups_desc">Показати \"Пропоновані групи\" у новинній стрічці</string>
<string name="show_stories">Показати Історії</string>
<string name="show_stories_desc">Показувати історії у стрічці</string>
<string name="facebook_ads">Реклама у Facebook</string>
<string name="facebook_ads_desc">Показувати вбудовану рекламу Facebook</string>
</resources>

View File

@ -46,4 +46,12 @@
<string name="options">Tuỳ chọn</string>
<string name="tab_customizer_instructions">Bấm giữ và kéo để sắp xếp biểu tượng trên cùng.</string>
<string name="no_new_notifications">Không có thông báo mới</string>
<!--
Template used to display human readable string;
For instance:
Today at 1:23 PM
Mar 13 at 9:00 AM
The first element is the day, and the second element is the time
-->
</resources>

View File

@ -40,4 +40,12 @@
<string name="file_chooser_not_found">未找到文件选择程序</string>
<string name="top_bar">顶栏</string>
<string name="bottom_bar">底栏</string>
<!--
Template used to display human readable string;
For instance:
Today at 1:23 PM
Mar 13 at 9:00 AM
The first element is the day, and the second element is the time
-->
</resources>

View File

@ -45,4 +45,12 @@
<string name="options">選項</string>
<string name="tab_customizer_instructions">長按及拖曳頂部圖標可重新排列位置</string>
<string name="no_new_notifications">沒有新通知。</string>
<!--
Template used to display human readable string;
For instance:
Today at 1:23 PM
Mar 13 at 9:00 AM
The first element is the day, and the second element is the time
-->
</resources>

View File

@ -9,4 +9,6 @@
<dimen name="tab_bar_height">50dp</dimen>
<dimen name="intro_bar_height">64dp</dimen>
<dimen name="badge_icon_size">20dp</dimen>
<dimen name="toolbar_icon_size">24dp</dimen>
</resources>

View File

@ -64,4 +64,15 @@
<!--Biometrics-->
<string name="biometrics_prompt_title">Authenticate Frost</string>
<string name="today">Today</string>
<string name="yesterday">Yesterday</string>
<!--
Template used to display human readable string;
For instance:
Today at 1:23 PM
Mar 13 at 9:00 AM
The first element is the day, and the second element is the time
-->
<string name="time_template">%1s at %2s</string>
</resources>

View File

@ -6,12 +6,22 @@
<item text="" />
-->
<version title="v2.3.0" />
<item text="Converted internals of Facebook data storage; auto migration will only work from 2.2.x to 2.3.x" />
<item text="Added notification widget" />
<item text="" />
<item text="" />
<item text="" />
<item text="" />
<item text="" />
<item text="" />
<item text="" />
<version title="v2.2.4" />
<item text="Show top bar to allow sharing posts" />
<item text="Fix unmuting videos when autoplay is enabled" />
<item text="Add shortcut to toggle autoplay in settings > behaviour" />
<item text="Update theme" />
<item text="" />
<version title="v2.2.3" />
<item text="Add ability to hide stories" />

View File

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?><!--
For sizing see:
https://developer.android.com/guide/practices/ui_guidelines/widget_design.html#anatomy_determining_size
-->
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:initialKeyguardLayout="@layout/widget_notifications"
android:initialLayout="@layout/widget_notifications"
android:minWidth="180dp"
android:minHeight="250dp"
android:previewImage="@drawable/notification_widget_preview" />

View File

@ -0,0 +1,195 @@
{
"formatVersion": 1,
"database": {
"version": 1,
"identityHash": "fe8f5b6c27f48d7e0733ee6819f06f40",
"entities": [
{
"tableName": "cookies",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`cookie_id` INTEGER NOT NULL, `name` TEXT, `cookie` TEXT, PRIMARY KEY(`cookie_id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "cookie_id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "cookie",
"columnName": "cookie",
"affinity": "TEXT",
"notNull": false
}
],
"primaryKey": {
"columnNames": [
"cookie_id"
],
"autoGenerate": false
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "notifications",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`notif_id` INTEGER NOT NULL, `userId` INTEGER NOT NULL, `href` TEXT NOT NULL, `title` TEXT, `text` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, `profileUrl` TEXT, `type` TEXT NOT NULL, `unread` INTEGER NOT NULL, PRIMARY KEY(`notif_id`, `userId`), FOREIGN KEY(`userId`) REFERENCES `cookies`(`cookie_id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "id",
"columnName": "notif_id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "userId",
"columnName": "userId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "href",
"columnName": "href",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "title",
"columnName": "title",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "text",
"columnName": "text",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "timestamp",
"columnName": "timestamp",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "profileUrl",
"columnName": "profileUrl",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "type",
"columnName": "type",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "unread",
"columnName": "unread",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"columnNames": [
"notif_id",
"userId"
],
"autoGenerate": false
},
"indices": [
{
"name": "index_notifications_notif_id",
"unique": false,
"columnNames": [
"notif_id"
],
"createSql": "CREATE INDEX `index_notifications_notif_id` ON `${TABLE_NAME}` (`notif_id`)"
},
{
"name": "index_notifications_userId",
"unique": false,
"columnNames": [
"userId"
],
"createSql": "CREATE INDEX `index_notifications_userId` ON `${TABLE_NAME}` (`userId`)"
}
],
"foreignKeys": [
{
"table": "cookies",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"userId"
],
"referencedColumns": [
"cookie_id"
]
}
]
},
{
"tableName": "frost_cache",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `type` TEXT NOT NULL, `lastUpdated` INTEGER NOT NULL, `contents` TEXT NOT NULL, PRIMARY KEY(`id`, `type`), FOREIGN KEY(`id`) REFERENCES `cookies`(`cookie_id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "type",
"columnName": "type",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "lastUpdated",
"columnName": "lastUpdated",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "contents",
"columnName": "contents",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"columnNames": [
"id",
"type"
],
"autoGenerate": false
},
"indices": [],
"foreignKeys": [
{
"table": "cookies",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"id"
],
"referencedColumns": [
"cookie_id"
]
}
]
}
],
"views": [],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, \"fe8f5b6c27f48d7e0733ee6819f06f40\")"
]
}
}

View File

@ -0,0 +1,40 @@
{
"formatVersion": 1,
"database": {
"version": 1,
"identityHash": "ee4d2fe4052ad3a1892be17681816c2c",
"entities": [
{
"tableName": "frost_generic",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`type` TEXT NOT NULL, `contents` TEXT NOT NULL, PRIMARY KEY(`type`))",
"fields": [
{
"fieldPath": "type",
"columnName": "type",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "contents",
"columnName": "contents",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"columnNames": [
"type"
],
"autoGenerate": false
},
"indices": [],
"foreignKeys": []
}
],
"views": [],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, \"ee4d2fe4052ad3a1892be17681816c2c\")"
]
}
}

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