Notifications: initial implementation

This commit is contained in:
Matthieu 2021-09-25 11:52:18 +00:00
parent 717ed496f5
commit 5c97fe096f
36 changed files with 699 additions and 133 deletions

View File

@ -16,6 +16,7 @@ android {
compileSdkVersion 30
buildToolsVersion '30.0.3'
compileOptions {
coreLibraryDesugaringEnabled true
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}
@ -100,6 +101,8 @@ android {
dependencies {
implementation fileTree(dir: 'libs', include: ['*.jar'])
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:1.1.5'
/**
* AndroidX dependencies:
*/
@ -123,7 +126,8 @@ dependencies {
implementation 'androidx.gridlayout:gridlayout:1.0.0'
implementation "androidx.activity:activity-ktx:1.3.1"
implementation 'androidx.fragment:fragment-ktx:1.3.6'
implementation "androidx.work:work-runtime-ktx:2.5.0"
implementation "androidx.work:work-runtime-ktx:2.6.0"
implementation 'androidx.work:work-testing:2.6.0'
// Use the most recent version of CameraX
def cameraX_version = '1.0.1'
@ -158,9 +162,9 @@ dependencies {
implementation 'com.squareup.okhttp3:okhttp:4.9.1'
implementation 'com.squareup.retrofit2:retrofit:2.9.0'
implementation 'com.squareup.retrofit2:converter-gson:2.9.0'
implementation 'com.squareup.retrofit2:adapter-rxjava2:2.9.0'
implementation 'io.reactivex.rxjava2:rxjava:2.2.21'
implementation 'io.reactivex.rxjava2:rxandroid:2.1.1'
implementation 'com.squareup.retrofit2:adapter-rxjava3:2.9.0'
implementation 'io.reactivex.rxjava3:rxjava:3.1.1'
implementation 'io.reactivex.rxjava3:rxandroid:3.0.0'
implementation 'com.github.connyduck:sparkbutton:4.1.0'
@ -180,14 +184,14 @@ dependencies {
implementation 'androidx.legacy:legacy-support-v4:1.0.0'
implementation 'com.mikepenz:materialdrawer:8.4.2'
implementation 'com.mikepenz:materialdrawer:8.4.3'
// Add for NavController support
implementation 'com.mikepenz:materialdrawer-nav:8.4.2'
//iconics
implementation "com.mikepenz:iconics-core:5.3.0"
implementation 'com.mikepenz:iconics-core:5.3.1'
implementation 'com.mikepenz:materialdrawer-iconics:8.4.2'
implementation "com.mikepenz:iconics-views:5.3.0"
implementation 'com.mikepenz:iconics-views:5.3.1'
implementation 'com.mikepenz:google-material-typeface:4.0.0.1-kotlin@aar'

View File

@ -544,6 +544,12 @@
license: The Apache Software License, Version 2.0
licenseUrl: http://www.apache.org/licenses/LICENSE-2.0.txt
url: https://developer.android.com/topic/libraries/architecture/index.html
- artifact: androidx.work:work-testing:+
name: work-testing
copyrightHolder: Google Inc.
license: The Apache Software License, Version 2.0
licenseUrl: http://www.apache.org/licenses/LICENSE-2.0.txt
url: https://developer.android.com/jetpack/androidx/releases/work#2.6.0
- artifact: androidx.work:work-runtime-ktx:+
name: work-runtime-ktx
copyrightHolder: Google Inc.
@ -628,8 +634,8 @@
license: The Apache Software License, Version 2.0
licenseUrl: http://www.apache.org/licenses/LICENSE-2.0.txt
url: https://github.com/square/retrofit
- artifact: com.squareup.retrofit2:adapter-rxjava2:+
name: adapter-rxjava2
- artifact: com.squareup.retrofit2:adapter-rxjava3:+
name: adapter-rxjava3
copyrightHolder: Square, Inc.
license: The Apache Software License, Version 2.0
licenseUrl: http://www.apache.org/licenses/LICENSE-2.0.txt
@ -646,13 +652,13 @@
license: The Apache Software License, Version 2.0
licenseUrl: http://www.apache.org/licenses/LICENSE-2.0.txt
url: https://square.github.io/okhttp/
- artifact: io.reactivex.rxjava2:rxandroid:+
- artifact: io.reactivex.rxjava3:rxandroid:+
name: rxandroid
copyrightHolder: Netflix, Inc
license: The Apache Software License, Version 2.0
licenseUrl: http://www.apache.org/licenses/LICENSE-2.0.txt
url: https://github.com/ReactiveX/RxAndroid
- artifact: io.reactivex.rxjava2:rxjava:+
- artifact: io.reactivex.rxjava3:rxjava:+
name: rxjava
copyrightHolder: Netflix, Inc.
license: The Apache Software License, Version 2.0

View File

@ -30,6 +30,7 @@ import org.junit.Rule
import org.junit.Test
import org.junit.rules.Timeout
import org.junit.runner.RunWith
import java.time.Instant
@RunWith(AndroidJUnit4::class)
@ -73,12 +74,12 @@ class IntentTest {
"https://testing2.pixeldroid.org/storage/avatars/default.jpg?v=0",
"https://testing2.pixeldroid.org/storage/avatars/default.jpg?v=0",
"", "", false, emptyList(), null,
"2021-02-11T23:44:03.000000Z", 0, 1, 2,
Instant.parse("2021-02-11T23:44:03.000000Z"), 0, 1, 2,
null, null, false, null)
val expectedIntent: Matcher<Intent> = CoreMatchers.allOf(
IntentMatchers.hasExtra(ACCOUNT_TAG, account)
)
"2021-02-11T23:44:03.000000Z"
waitForView(R.id.description)
//Click the mention

View File

@ -11,6 +11,7 @@ import androidx.test.espresso.assertion.ViewAssertions.matches
import androidx.test.espresso.matcher.ViewMatchers.*
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.google.android.material.tabs.TabLayout
import org.hamcrest.CoreMatchers.anyOf
import org.pixeldroid.app.testUtility.*
import org.pixeldroid.app.utils.db.AppDatabase
import org.junit.After
@ -81,7 +82,11 @@ class MockedServerTest {
waitForView(R.id.username)
onView(withId(R.id.username)).check(matches(withSubstring("User ")))
onView(withId(R.id.username)).check(matches(anyOf(
withSubstring("User "),
withSubstring("PixelDroid Developer"),
withSubstring("Testi Testo")
)))
}
@Test

View File

@ -0,0 +1,117 @@
package org.pixeldroid.app
import android.content.Context
import androidx.test.core.app.ActivityScenario
import androidx.test.core.app.ApplicationProvider
import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.assertion.ViewAssertions.matches
import androidx.test.espresso.matcher.ViewMatchers.withId
import androidx.test.espresso.matcher.ViewMatchers.withText
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.uiautomator.*
import androidx.work.ListenableWorker
import androidx.work.testing.TestListenableWorkerBuilder
import kotlinx.coroutines.runBlocking
import org.hamcrest.CoreMatchers
import org.hamcrest.MatcherAssert
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.junit.runners.JUnit4
import org.pixeldroid.app.settings.AboutActivity
import org.pixeldroid.app.testUtility.*
import org.pixeldroid.app.utils.api.objects.Account
import org.pixeldroid.app.utils.api.objects.Notification
import org.pixeldroid.app.utils.db.AppDatabase
import org.pixeldroid.app.utils.notificationsWorker.NotificationsWorker
import java.time.Instant
@RunWith(JUnit4::class)
class NotificationWorkerTest {
private lateinit var context: Context
private lateinit var activityScenario: ActivityScenario<AboutActivity>
private val uiDevice by lazy { UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) }
private lateinit var db: AppDatabase
private val secondToLatestNotification: Notification =
Notification(
id = "1",
type = Notification.NotificationType.follow,
created_at = Instant.parse("2021-09-19T19:23:30Z"),
account = Account(
id = "344399325768278017",
username = "pixeldroid",
acct = "pixeldroid",
url = "https://testing.pixeldroid.org/pixeldroid",
display_name = "PixelDroid",
note = "",
avatar = "https://testing.pixeldroid.org/storage/avatars/default.jpg?v=0",
avatar_static = null,
header = null,
header_static = null,
locked = false,
emojis = null,
discoverable = null,
created_at = Instant.parse("1970-01-01T00:00:00Z"),
statuses_count = 0,
followers_count = 0,
following_count = 1,
moved = null,
fields = null,
bot = null,
source = null
),
status = null,
user_id = "344399082242686977",
instance_uri = "https://testing.pixeldroid.org"
)
@Before
fun setup() {
context = ApplicationProvider.getApplicationContext()
db = initDB(context)
db.clearAllTables()
db.instanceDao().insertInstance(
testiTestoInstance
)
db.userDao().insertUser(
testiTesto
)
runBlocking {
db.notificationDao().insertAll(listOf(secondToLatestNotification))
}
db.close()
activityScenario = ActivityScenario.launch(AboutActivity::class.java)
}
@Test
fun testNotificationWorker() {
val expectedAppName = context.getString(R.string.app_name)
val expectedText = "user1 followed you"
// Run the worker synchronously
val worker = TestListenableWorkerBuilder<NotificationsWorker>(context).build()
val result = worker.startWork().get()
// Check worker returns success (which doesn't mean much, but is a good start)
MatcherAssert.assertThat(result, CoreMatchers.`is`(ListenableWorker.Result.success()))
//Open notification shade
uiDevice.openNotification()
uiDevice.wait(Until.hasObject(By.textStartsWith(expectedAppName)), 5000)
val text: UiObject2 = uiDevice.findObject(By.textStartsWith(expectedText))
text.click()
uiDevice.wait(Until.hasObject(By.textStartsWith(expectedText)), 5000)
waitForView(R.id.notification_type)
onView(first(withId(R.id.notification_type)))
.check(matches(withText(expectedText)))
}
}

View File

@ -27,6 +27,9 @@ import org.junit.*
import org.junit.rules.Timeout
import org.junit.runner.RunWith
import java.text.SimpleDateFormat
import java.time.Instant
import java.time.OffsetDateTime
import java.time.format.DateTimeFormatter
@RunWith(AndroidJUnit4::class)
@ -40,7 +43,7 @@ class PostTest {
@Before
fun before(){
context = InstrumentationRegistry.getInstrumentation().targetContext
context = getInstrumentation().targetContext
db = initDB(context)
db.clearAllTables()
db.instanceDao().insertInstance(
@ -66,7 +69,8 @@ class PostTest {
username = "SQDFSQDF",
url = "$INSTANCE_URI/pixeldroid",
),
media_attachments = listOf(attachment)
media_attachments = listOf(attachment),
created_at = Instant.now().minusSeconds(3600)
)
val intent = Intent(context, PostActivity::class.java)
intent.putExtra(Status.POST_TAG, post)
@ -102,7 +106,8 @@ class PostTest {
username = "douze",
url = "$INSTANCE_URI/pixeldroid",
),
media_attachments = listOf(attachment1, attachment2)
media_attachments = listOf(attachment1, attachment2),
created_at = Instant.now().minusSeconds(3600)
)
val intent = Intent(context, PostActivity::class.java)
intent.putExtra(Status.POST_TAG, post)
@ -134,7 +139,8 @@ class PostTest {
username = "douze",
url = "$INSTANCE_URI/pixeldroid",
),
media_attachments = listOf(attachment)
media_attachments = listOf(attachment),
created_at = Instant.now().minusSeconds(3600)
)
val intent = Intent(context, PostActivity::class.java)
intent.putExtra(Status.POST_TAG, post)
@ -165,7 +171,8 @@ class PostTest {
username = "douze",
url = "$INSTANCE_URI/pixeldroid",
),
media_attachments = listOf(attachment1, attachment2)
media_attachments = listOf(attachment1, attachment2),
created_at = Instant.now().minusSeconds(3600)
)
val intent = Intent(context, PostActivity::class.java)
intent.putExtra(Status.POST_TAG, post)
@ -181,13 +188,13 @@ class PostTest {
@Test
fun getNLikesReturnsCorrectFormat() {
val status = Status(id="140364967936397312", uri="https://pixelfed.de/p/Miike/140364967936397312",
created_at= SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.hhmmss'Z'").parse("2020-03-03T08:00:16.000000Z"),
created_at= OffsetDateTime.parse("2020-03-03T08:00:16+00:00").toInstant(),
account= Account(id="115114166443970560", username="Miike", acct="Miike",
url="https://pixelfed.de/Miike", display_name="Miike Duart", note="",
avatar="https://pixelfed.de/storage/avatars/011/511/416/644/397/056/0/ZhaopLJWTWJ3hsVCS5pS_avatar.png?v=d4735e3a265e16eee03f59718b9b5d03019c07d8b6c51f90da3a666eec13ab35",
avatar_static="https://pixelfed.de/storage/avatars/011/511/416/644/397/056/0/ZhaopLJWTWJ3hsVCS5pS_avatar.png?v=d4735e3a265e16eee03f59718b9b5d03019c07d8b6c51f90da3a666eec13ab35",
header="", header_static="", locked=false, emojis= emptyList(), discoverable=false,
created_at="2019-12-24T15:42:35.000000Z", statuses_count=71, followers_count=14,
created_at=Instant.parse("2019-12-24T15:42:35.000000Z"), statuses_count=71, followers_count=14,
following_count=0, moved=null, fields=null, bot=false, source=null),
content="""Day 8 <a href="https://pixelfed.de/discover/tags/rotavicentina?src=hash" title="#rotavicentina" class="u-url hashtag" rel="external nofollow noopener">#rotavicentina</a> <a href="https://pixelfed.de/discover/tags/hiking?src=hash" title="#hiking" class="u-url hashtag" rel="external nofollow noopener">#hiking</a> <a href="https://pixelfed.de/discover/tags/nature?src=hash" title="#nature" class="u-url hashtag" rel="external nofollow noopener">#nature</a>""",
visibility=Status.Visibility.public, sensitive=false, spoiler_text="",
@ -208,13 +215,13 @@ class PostTest {
@Test
fun getNSharesReturnsCorrectFormat() {
val status = Status(id="140364967936397312", uri="https://pixelfed.de/p/Miike/140364967936397312",
created_at= SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.hhmmss'Z'").parse("2020-03-03T08:00:16.000000Z"),
created_at= Instant.parse("2020-03-03T08:00:16.00Z"),
account= Account(id="115114166443970560", username="Miike", acct="Miike",
url="https://pixelfed.de/Miike", display_name="Miike Duart", note="",
avatar="https://pixelfed.de/storage/avatars/011/511/416/644/397/056/0/ZhaopLJWTWJ3hsVCS5pS_avatar.png?v=d4735e3a265e16eee03f59718b9b5d03019c07d8b6c51f90da3a666eec13ab35",
avatar_static="https://pixelfed.de/storage/avatars/011/511/416/644/397/056/0/ZhaopLJWTWJ3hsVCS5pS_avatar.png?v=d4735e3a265e16eee03f59718b9b5d03019c07d8b6c51f90da3a666eec13ab35",
header="", header_static="", locked=false, emojis= emptyList(), discoverable=false,
created_at="2019-12-24T15:42:35.000000Z", statuses_count=71, followers_count=14,
created_at=Instant.parse("2019-12-24T15:42:35.000000Z"), statuses_count=71, followers_count=14,
following_count=0, moved=null, fields=null, bot=false, source=null),
content="""Day 8 <a href="https://pixelfed.de/discover/tags/rotavicentina?src=hash" title="#rotavicentina" class="u-url hashtag" rel="external nofollow noopener">#rotavicentina</a> <a href="https://pixelfed.de/discover/tags/hiking?src=hash" title="#hiking" class="u-url hashtag" rel="external nofollow noopener">#hiking</a> <a href="https://pixelfed.de/discover/tags/nature?src=hash" title="#nature" class="u-url hashtag" rel="external nofollow noopener">#nature</a>""",
visibility=Status.Visibility.public, sensitive=false, spoiler_text="",

View File

@ -19,6 +19,7 @@ import org.junit.After
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import java.time.Instant
@RunWith(AndroidJUnit4::class)
class ProfileTest {
@ -37,7 +38,7 @@ class ProfileTest {
db.close()
val intent = Intent(context, ProfileActivity::class.java)
val account = Account(id = "265472486651596800", username = "pixeldroid", acct = "pixeldroid", url = "https://testing2.pixeldroid.org/pixeldroid", display_name = "PixelDroid Developer", avatar = "https://testing2.pixeldroid.org/storage/avatars/default.jpg?v=0", avatar_static = "https://testing2.pixeldroid.org/storage/avatars/default.jpg?v=0", locked = false, emojis = arrayListOf(), discoverable = null, created_at = "2021-02-11T13:32:53.000000Z", statuses_count = 1, followers_count = 1, following_count = 1, moved = null, fields = null, bot = false, source = null)
val account = Account(id="344399325768278017", username="pixeldroid", acct="pixeldroid", url="https://testing.pixeldroid.org/pixeldroid", display_name="PixelDroid Developer", note="", avatar="https://testing.pixeldroid.org/storage/avatars/default.jpg?v=0", avatar_static="https://testing.pixeldroid.org/storage/avatars/default.jpg?v=0", header="", header_static="", locked=false, emojis= emptyList(), discoverable=null, created_at=Instant.parse("2021-09-17T08:39:57Z"), statuses_count=0, followers_count=1, following_count=1, moved=null, fields=null, bot=false, source=null)
intent.putExtra(Account.ACCOUNT_TAG, account)
activityScenario = ActivityScenario.launch(intent)
onView(withId(R.id.profileRefreshLayout)).perform(swipeDown())
@ -86,7 +87,7 @@ class ProfileTest {
waitForView(R.id.account_entry_username)
// Open follower's profile
onView(ViewMatchers.withText("testi testo")).perform((ViewActions.click()))
onView(ViewMatchers.withText("Testi Testo")).perform((ViewActions.click()))
waitForView(R.id.editButton)

View File

@ -9,6 +9,8 @@ import android.os.Bundle
import android.view.View
import android.view.inputmethod.InputMethodManager
import androidx.lifecycle.lifecycleScope
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.WorkManager
import org.pixeldroid.app.databinding.ActivityLoginBinding
import org.pixeldroid.app.utils.*
import org.pixeldroid.app.utils.api.PixelfedAPI
@ -16,8 +18,13 @@ import org.pixeldroid.app.utils.api.objects.*
import org.pixeldroid.app.utils.db.addUser
import org.pixeldroid.app.utils.db.storeInstance
import kotlinx.coroutines.*
import org.pixeldroid.app.utils.notificationsWorker.NotificationsWorker
import org.pixeldroid.app.utils.notificationsWorker.makeChannelGroupId
import org.pixeldroid.app.utils.notificationsWorker.makeNotificationChannels
import retrofit2.HttpException
import java.io.IOException
import java.lang.IllegalArgumentException
import java.lang.NullPointerException
/**
Overview of the flow of the login process: (boxes are requests done in parallel,
@ -310,14 +317,40 @@ class LoginActivity : BaseActivity() {
clientSecret = clientSecret
)
apiHolder.setToCurrentUser()
val intent = Intent(this@LoginActivity, MainActivity::class.java)
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
startActivity(intent)
} catch (exception: IOException) {
return failedRegistration(getString(R.string.verify_credentials))
} catch (exception: HttpException) {
return failedRegistration(getString(R.string.verify_credentials))
}
fetchNotifications()
val intent = Intent(this@LoginActivity, MainActivity::class.java)
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
startActivity(intent)
}
// Fetch the latest notifications of this account, to avoid launching old notifications
private suspend fun fetchNotifications() {
val user = db.userDao().getActiveUser()!!
try {
val notifications = apiHolder.api!!.notifications()
notifications.forEach{it.user_id = user.user_id; it.instance_uri = user.instance_uri}
db.notificationDao().insertAll(notifications)
} catch (exception: IOException) {
return failedRegistration(getString(R.string.login_notifications))
} catch (exception: HttpException) {
return failedRegistration(getString(R.string.login_notifications))
} catch (exception: NullPointerException) {
return failedRegistration(getString(R.string.login_notifications))
}
makeNotificationChannels(
applicationContext,
user.fullHandle,
makeChannelGroupId(user)
)
}
}

View File

@ -41,10 +41,15 @@ import org.pixeldroid.app.searchDiscover.SearchDiscoverFragment
import org.pixeldroid.app.settings.SettingsActivity
import org.pixeldroid.app.utils.BaseActivity
import org.pixeldroid.app.utils.db.addUser
import org.pixeldroid.app.utils.notificationsWorker.enablePullNotifications
import org.pixeldroid.app.utils.db.entities.HomeStatusDatabaseEntity
import org.pixeldroid.app.utils.db.entities.PublicFeedStatusDatabaseEntity
import org.pixeldroid.app.utils.db.entities.UserDatabaseEntity
import org.pixeldroid.app.utils.hasInternet
import org.pixeldroid.app.utils.notificationsWorker.NotificationsWorker.Companion.INSTANCE_NOTIFICATION_TAG
import org.pixeldroid.app.utils.notificationsWorker.NotificationsWorker.Companion.SHOW_NOTIFICATION_TAG
import org.pixeldroid.app.utils.notificationsWorker.NotificationsWorker.Companion.USER_NOTIFICATION_TAG
import org.pixeldroid.app.utils.notificationsWorker.removeNotificationChannelsFromAccount
import retrofit2.HttpException
import java.io.IOException
@ -56,6 +61,7 @@ class MainActivity : BaseActivity() {
companion object {
const val ADD_ACCOUNT_IDENTIFIER: Long = -13
const val LOG_OUT_REQUESTED = "LOG_OUT_REQUESTED"
}
private lateinit var binding: ActivityMainBinding
@ -70,6 +76,8 @@ class MainActivity : BaseActivity() {
//get the currently active user
user = db.userDao().getActiveUser()
if (notificationFromOtherUser()) return
//Check if we have logged in and gotten an access token
if (user == null) {
finish()
@ -96,9 +104,43 @@ class MainActivity : BaseActivity() {
}
)
setupTabs(tabs)
val showNotification: Boolean = intent.getBooleanExtra(SHOW_NOTIFICATION_TAG, false)
if(showNotification){
binding.viewPager.currentItem = 3
}
enablePullNotifications(this)
}
}
// Checks if the activity was launched from a notification from another account than the
// current active one, and if so switches to that account
private fun notificationFromOtherUser(): Boolean {
val userOfNotification: String? = intent.extras?.getString(USER_NOTIFICATION_TAG)
val instanceOfNotification: String? = intent.extras?.getString(INSTANCE_NOTIFICATION_TAG)
if (userOfNotification != null && instanceOfNotification != null
&& (userOfNotification != user?.user_id
|| instanceOfNotification != user?.instance_uri)
) {
switchUser(userOfNotification)
val newIntent = Intent(this, MainActivity::class.java)
newIntent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
if (intent.getBooleanExtra(SHOW_NOTIFICATION_TAG, false)) {
newIntent.putExtra(SHOW_NOTIFICATION_TAG, true)
}
finish()
startActivity(newIntent)
return true
}
return false
}
private fun setupDrawer() {
binding.mainDrawerButton.setOnClickListener{
binding.drawerLayout.open()
@ -173,6 +215,9 @@ class MainActivity : BaseActivity() {
private fun logOut(){
finish()
removeNotificationChannelsFromAccount(applicationContext, user)
db.runInTransaction {
db.userDao().deleteActiveUsers()
@ -225,9 +270,8 @@ class MainActivity : BaseActivity() {
return false
}
db.userDao().deActivateActiveUsers()
db.userDao().activateUser(profile.identifier.toString())
apiHolder.setToCurrentUser()
switchUser(profile.identifier.toString())
val intent = Intent(this, MainActivity::class.java)
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
@ -237,6 +281,12 @@ class MainActivity : BaseActivity() {
return false
}
private fun switchUser(userId: String) {
db.userDao().deActivateActiveUsers()
db.userDao().activateUser(userId)
apiHolder.setToCurrentUser()
}
private inline fun primaryDrawerItem(block: PrimaryDrawerItem.() -> Unit): PrimaryDrawerItem {
return PrimaryDrawerItem()
.apply {
@ -262,7 +312,7 @@ class MainActivity : BaseActivity() {
iconUrl = user.avatar_static
isNameShown = true
identifier = user.user_id.toLong()
descriptionText = "@${user.username}@${user.instance_uri.removePrefix("https://")}"
descriptionText = user.fullHandle
}
}.toMutableList()

View File

@ -22,6 +22,9 @@ import androidx.core.net.toFile
import androidx.core.net.toUri
import androidx.lifecycle.lifecycleScope
import com.google.android.material.snackbar.Snackbar
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.disposables.Disposable
import io.reactivex.rxjava3.schedulers.Schedulers
import org.pixeldroid.app.MainActivity
import org.pixeldroid.app.R
import org.pixeldroid.app.databinding.ActivityPostCreationBinding
@ -33,9 +36,6 @@ import org.pixeldroid.app.utils.BaseActivity
import org.pixeldroid.app.utils.api.objects.Attachment
import org.pixeldroid.app.utils.db.entities.InstanceDatabaseEntity
import org.pixeldroid.app.utils.db.entities.UserDatabaseEntity
import io.reactivex.android.schedulers.AndroidSchedulers
import io.reactivex.disposables.Disposable
import io.reactivex.schedulers.Schedulers
import okhttp3.MultipartBody
import retrofit2.HttpException
import java.io.File

View File

@ -1,7 +1,7 @@
package org.pixeldroid.app.postCreation
import io.reactivex.Observable
import io.reactivex.subjects.PublishSubject
import io.reactivex.rxjava3.core.Observable
import io.reactivex.rxjava3.subjects.PublishSubject
import okhttp3.MediaType
import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.RequestBody

View File

@ -10,7 +10,6 @@ import android.text.style.URLSpan
import android.util.Log
import android.view.View
import android.widget.TextView
import android.widget.Toast
import androidx.core.text.toSpanned
import androidx.lifecycle.LifecycleCoroutineScope
import org.pixeldroid.app.R
@ -22,6 +21,8 @@ import org.pixeldroid.app.utils.di.PixelfedAPIHolder
import java.net.URI
import java.net.URISyntaxException
import java.text.ParseException
import java.time.Instant
import java.time.ZoneOffset
import java.util.*
fun fromHtml(html: String): Spanned {
@ -128,18 +129,18 @@ fun parseHTMLText(
}
fun setTextViewFromISO8601(date: Date, textView: TextView, absoluteTime: Boolean, context: Context) {
val now = Date().time
fun setTextViewFromISO8601(date: Instant, textView: TextView, absoluteTime: Boolean, context: Context) {
val now = Date.from(Instant.now()).time
try {
val then = date.time
val formattedDate = android.text.format.DateUtils
.getRelativeTimeSpanString(then, now,
android.text.format.DateUtils.SECOND_IN_MILLIS,
android.text.format.DateUtils.FORMAT_ABBREV_RELATIVE)
val then = Date.from(date).time
val formattedDate: String = android.text.format.DateUtils
.getRelativeTimeSpanString(then, now,
android.text.format.DateUtils.SECOND_IN_MILLIS,
android.text.format.DateUtils.FORMAT_ABBREV_RELATIVE).toString()
textView.text = if(absoluteTime) context.getString(R.string.posted_on).format(date)
else "$formattedDate"
else formattedDate
} catch (e: ParseException) {
e.printStackTrace()

View File

@ -2,6 +2,7 @@ package org.pixeldroid.app.settings
import android.content.Intent
import android.content.SharedPreferences
import android.os.Build
import android.os.Bundle
import androidx.preference.PreferenceFragmentCompat
import androidx.preference.PreferenceManager
@ -72,13 +73,18 @@ class SettingsActivity : BaseActivity(), SharedPreferences.OnSharedPreferenceCha
putBoolean("restartMain", true)
}
intent.putExtras(savedInstanceState)
super.startActivity(intent)
finish()
super.startActivity(intent)
}
class SettingsFragment : PreferenceFragmentCompat() {
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
setPreferencesFromResource(R.xml.root_preferences, rootKey)
//Hide Notification setting for Android versions where it doesn't work
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
preferenceScreen.removePreference(preferenceManager.findPreference("notification"))
}
}
}

View File

@ -1,7 +1,8 @@
package org.pixeldroid.app.utils.api
import com.google.gson.*
import io.reactivex.rxjava3.core.Observable
import org.pixeldroid.app.utils.api.objects.*
import io.reactivex.Observable
import okhttp3.MultipartBody
import okhttp3.OkHttpClient
import org.pixeldroid.app.utils.db.AppDatabase
@ -10,10 +11,13 @@ import org.pixeldroid.app.utils.di.PixelfedAPIHolder
import org.pixeldroid.app.utils.di.TokenAuthenticator
import retrofit2.Response
import retrofit2.Retrofit
import retrofit2.adapter.rxjava2.RxJava2CallAdapterFactory
import retrofit2.adapter.rxjava3.RxJava3CallAdapterFactory
import retrofit2.converter.gson.GsonConverterFactory
import retrofit2.http.*
import retrofit2.http.Field
import java.time.Instant
import java.time.format.DateTimeFormatter
/*
Implements the Pixelfed API
@ -29,14 +33,28 @@ interface PixelfedAPI {
fun createFromUrl(baseUrl: String): PixelfedAPI {
return Retrofit.Builder()
.baseUrl(baseUrl)
.addConverterFactory(GsonConverterFactory.create())
.addCallAdapterFactory(RxJava2CallAdapterFactory.create())
.addConverterFactory(GsonConverterFactory.create(gSonInstance))
.addCallAdapterFactory(RxJava3CallAdapterFactory.create())
.build().create(PixelfedAPI::class.java)
}
private var gSonInstance: Gson = GsonBuilder()
.registerTypeAdapter(
Instant::class.java,
JsonDeserializer { json: JsonElement, _, _ ->
DateTimeFormatter.ISO_OFFSET_DATE_TIME.parse(
json.asString, Instant::from
)
} as JsonDeserializer<Instant>).registerTypeAdapter(
Instant::class.java,
JsonSerializer { src: Instant, _, _ ->
JsonPrimitive(DateTimeFormatter.ISO_INSTANT.format(src))
})
.create()
private val intermediate: Retrofit.Builder = Retrofit.Builder()
.addConverterFactory(GsonConverterFactory.create())
.addCallAdapterFactory(RxJava2CallAdapterFactory.create())
.addConverterFactory(GsonConverterFactory.create(gSonInstance))
.addCallAdapterFactory(RxJava3CallAdapterFactory.create())
fun apiForUser(

View File

@ -9,6 +9,7 @@ import org.pixeldroid.app.utils.api.PixelfedAPI
import retrofit2.HttpException
import java.io.IOException
import java.io.Serializable
import java.time.Instant
/*
Represents a user and their associated profile.
@ -32,7 +33,7 @@ data class Account(
val emojis: List<Emoji>? = null,
val discoverable: Boolean? = true,
//Statistical attributes
val created_at: String? = "", //ISO 8601 Datetime (maybe can use a date type)
val created_at: Instant? = null, //ISO 8601 Datetime
val statuses_count: Int? = 0,
val followers_count: Int? = 0,
val following_count: Int? = 0,

View File

@ -5,7 +5,7 @@ import androidx.room.ForeignKey
import androidx.room.Index
import org.pixeldroid.app.utils.db.entities.UserDatabaseEntity
import java.io.Serializable
import java.util.Date
import java.time.Instant
/*
Represents a notification of an event relevant to the user.
@ -27,7 +27,7 @@ data class Notification(
//Required attributes
override val id: String,
val type: NotificationType?,
val created_at: Date?, //ISO 8601 Datetime
val created_at: Instant? = null, //ISO 8601 Datetime
val account: Account?,
//Optional attributes
val status: Status? = null,

View File

@ -1,5 +1,22 @@
package org.pixeldroid.app.utils.api.objects
import java.io.Serializable
import java.time.Instant
class Poll : Serializable
data class Poll (
val id: String?,
val expires_at: Instant? = null, //ISO 8601 Datetime, or null if poll does not end
val expired: Boolean?,
val multiple: Boolean, //Does the poll allow multiple-choice answers?
val votes_count: Int?,
val voters_count: Int?,
val voted: Boolean?, //null if gotten without user token
val own_votes: List<Int?>?,
val options: List<Option?>?,
val emojis: List<Emoji?>?
): Serializable {
data class Option(
val title: String?,
val votes_count: Int? //null if result not published yet
)
}

View File

@ -13,7 +13,7 @@ import org.pixeldroid.app.R
import org.pixeldroid.app.posts.getDomain
import java.io.File
import java.io.Serializable
import java.util.*
import java.time.Instant
/**
Represents a status posted by an account.
@ -24,7 +24,7 @@ open class Status(
//Base attributes
override val id: String,
val uri: String? = "",
val created_at: Date? = Date(0), //ISO 8601 Datetime
val created_at: Instant? = null, //ISO 8601 Datetime
val account: Account?,
val content: String? = "", //HTML
val visibility: Visibility? = Visibility.public,

View File

@ -22,7 +22,7 @@ import org.pixeldroid.app.utils.api.objects.Notification
PublicFeedStatusDatabaseEntity::class,
Notification::class
],
version = 3
version = 4
)
@TypeConverters(Converters::class)
abstract class AppDatabase : RoomDatabase() {

View File

@ -4,11 +4,27 @@ import androidx.room.TypeConverter
import com.google.gson.Gson
import com.google.gson.reflect.TypeToken
import org.pixeldroid.app.utils.api.objects.*
import java.time.Instant
import java.time.format.DateTimeFormatter
import java.util.*
class Converters {
private val gson = Gson()
private val formatter = DateTimeFormatter.ISO_OFFSET_DATE_TIME
private val instantFormatter = DateTimeFormatter.ISO_INSTANT
@TypeConverter
fun toInstant(timestamp: String?): Instant? =
timestamp?.let {
instantFormatter.parse(it, Instant::from)
}
@TypeConverter
fun fromInstant(time: Instant?): String? =
time?.let { instantFormatter.format(it) }
@TypeConverter
fun listToJson(list: List<String>): String = gson.toJson(list)
@ -16,12 +32,6 @@ class Converters {
fun jsonToList(json: String): List<String> =
gson.fromJson(json, Array<String>::class.java).toList()
@TypeConverter
fun dateToJson(date: Date): String = gson.toJson(date)
@TypeConverter
fun jsonToDate(json: String): Date = gson.fromJson(json, Date::class.java)
@TypeConverter
fun accountToJson(account: Account): String = gson.toJson(account)

View File

@ -33,12 +33,14 @@ interface UserDao {
@Query("UPDATE users SET isActive=0")
fun deActivateActiveUsers()
//TODO also check instance_uri
@Query("UPDATE users SET isActive=1 WHERE user_id=:id")
fun activateUser(id: String)
@Query("DELETE FROM users WHERE isActive=1")
fun deleteActiveUsers()
//TODO also check instance_uri
@Query("SELECT * FROM users WHERE user_id=:id LIMIT 1")
fun getUserWithId(id: String): UserDatabaseEntity
}

View File

@ -12,9 +12,13 @@ interface NotificationDao: FeedContentDao<Notification> {
override suspend fun clearFeedContent(userId: String, instanceUri: String)
@Query("""SELECT * FROM notifications WHERE user_id=:userId AND instance_uri=:instanceUri
ORDER BY CAST(created_at AS FLOAT) DESC""")
ORDER BY datetime(created_at) DESC""")
override fun feedContent(userId: String, instanceUri: String): PagingSource<Int, Notification>
@Query("""SELECT * FROM notifications WHERE user_id=:userId AND instance_uri=:instanceUri
ORDER BY datetime(created_at) DESC LIMIT 1""")
fun latestNotification(userId: String, instanceUri: String): Notification?
@Query("DELETE FROM notifications WHERE user_id=:userId AND instance_uri=:instanceUri AND id=:id")
override suspend fun delete(id: String, userId: String, instanceUri: String)
}

View File

@ -9,7 +9,7 @@ import org.pixeldroid.app.utils.db.entities.HomeStatusDatabaseEntity
@Dao
interface HomePostDao: FeedContentDao<HomeStatusDatabaseEntity> {
@Query("""SELECT * FROM homePosts WHERE user_id=:userId AND instance_uri=:instanceUri
ORDER BY CAST(created_at AS FLOAT)""")
ORDER BY datetime(created_at) DESC""")
override fun feedContent(userId: String, instanceUri: String): PagingSource<Int, HomeStatusDatabaseEntity>
@Query("DELETE FROM homePosts WHERE user_id=:userId AND instance_uri=:instanceUri")

View File

@ -9,7 +9,7 @@ import org.pixeldroid.app.utils.db.entities.PublicFeedStatusDatabaseEntity
@Dao
interface PublicPostDao: FeedContentDao<PublicFeedStatusDatabaseEntity> {
@Query("""SELECT * FROM publicPosts WHERE user_id=:userId AND instance_uri=:instanceUri
ORDER BY CAST(created_at AS FLOAT)""")
ORDER BY datetime(created_at) DESC""")
override fun feedContent(userId: String, instanceUri: String): PagingSource<Int, PublicFeedStatusDatabaseEntity>
@Query("DELETE FROM publicPosts WHERE user_id=:userId AND instance_uri=:instanceUri")

View File

@ -4,7 +4,7 @@ import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.Index
import org.pixeldroid.app.utils.api.objects.*
import java.util.*
import java.time.Instant
@Entity(
tableName = "homePosts",
@ -56,7 +56,7 @@ class HomeStatusDatabaseEntity(
//Constructor to make Room happy. This sucks, and I know it.
constructor(id: String,
uri: String? = "",
created_at: Date? = Date(0),
created_at: Instant?,
account: Account?,
content: String? = "",
visibility: Visibility? = Visibility.public,

View File

@ -4,7 +4,7 @@ import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.Index
import org.pixeldroid.app.utils.api.objects.*
import java.util.*
import java.time.Instant
@Entity(
tableName = "publicPosts",
@ -56,7 +56,7 @@ class PublicFeedStatusDatabaseEntity(
//Constructor to make Room happy. This sucks, and I know it.
constructor(id: String,
uri: String? = "",
created_at: Date? = Date(0),
created_at: Instant?,
account: Account?,
content: String? = "",
visibility: Visibility? = Visibility.public,

View File

@ -3,6 +3,7 @@ package org.pixeldroid.app.utils.db.entities
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.Index
import java.io.Serializable
@Entity(
tableName = "users",
@ -27,4 +28,7 @@ data class UserDatabaseEntity(
val refreshToken: String?,
val clientId: String,
val clientSecret: String
)
): Serializable {
val fullHandle: String
get() = "@${username}@${instance_uri.removePrefix("https://")}"
}

View File

@ -7,6 +7,7 @@ import org.pixeldroid.app.utils.PixelDroidApplication
import org.pixeldroid.app.utils.db.AppDatabase
import org.pixeldroid.app.utils.BaseFragment
import dagger.Component
import org.pixeldroid.app.utils.notificationsWorker.NotificationsWorker
import javax.inject.Singleton
@ -16,6 +17,7 @@ interface ApplicationComponent {
fun inject(application: PixelDroidApplication?)
fun inject(activity: BaseActivity?)
fun inject(feedFragment: BaseFragment)
fun inject(notificationsWorker: NotificationsWorker)
val context: Context?
val application: Application?

View File

@ -0,0 +1,21 @@
package org.pixeldroid.app.utils.notificationsWorker
import android.content.Context
import androidx.work.*
import java.util.concurrent.TimeUnit
fun enablePullNotifications(context: Context) {
val workManager = WorkManager.getInstance(context)
val tag = "NOTIFICATION_PULL_TAG"
workManager.cancelAllWorkByTag(tag)
val workRequest: WorkRequest = PeriodicWorkRequestBuilder<NotificationsWorker>(
PeriodicWorkRequest.MIN_PERIODIC_INTERVAL_MILLIS, TimeUnit.MILLISECONDS,
PeriodicWorkRequest.MIN_PERIODIC_FLEX_MILLIS, TimeUnit.MILLISECONDS
)
.addTag(tag)
.setConstraints(
Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build()
)
.build()
workManager.enqueue(workRequest)
}

View File

@ -0,0 +1,267 @@
package org.pixeldroid.app.utils.notificationsWorker
import android.app.NotificationChannel
import android.app.NotificationChannelGroup
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.graphics.Color
import android.os.Build
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.work.CoroutineWorker
import androidx.work.WorkerParameters
import org.pixeldroid.app.MainActivity
import org.pixeldroid.app.R
import org.pixeldroid.app.posts.PostActivity
import org.pixeldroid.app.utils.PixelDroidApplication
import org.pixeldroid.app.utils.api.PixelfedAPI.Companion.apiForUser
import org.pixeldroid.app.utils.api.objects.Notification
import org.pixeldroid.app.utils.api.objects.Notification.NotificationType.*
import org.pixeldroid.app.utils.api.objects.Status
import org.pixeldroid.app.utils.db.AppDatabase
import org.pixeldroid.app.utils.db.entities.UserDatabaseEntity
import org.pixeldroid.app.utils.di.PixelfedAPIHolder
import retrofit2.HttpException
import java.io.IOException
import java.time.Instant
import javax.inject.Inject
class NotificationsWorker(
context: Context,
params: WorkerParameters
) : CoroutineWorker(context, params) {
@Inject
lateinit var db: AppDatabase
@Inject
lateinit var apiHolder: PixelfedAPIHolder
override suspend fun doWork(): Result {
(applicationContext as PixelDroidApplication).getAppComponent().inject(this)
val users: List<UserDatabaseEntity> = db.userDao().getAll()
for (user in users){
val uniqueUserId = makeChannelGroupId(user)
val notificationsEnabledForUser = makeNotificationChannels(
applicationContext,
user.fullHandle,
uniqueUserId
)
//if notifications are disabled for this user, move on to next user
if(!notificationsEnabledForUser) continue
// Get newest notification from database
var previouslyLatestNotification: Notification? = db.notificationDao().latestNotification(user.user_id, user.instance_uri)
val api = apiForUser(user, db, apiHolder)
try {
// Request notifications from server
var newNotifications: List<Notification>? = api.notifications(
since_id = previouslyLatestNotification?.id
)
while (!newNotifications.isNullOrEmpty()
&& newNotifications.map { it.created_at ?: Instant.MIN }
.maxOrNull()!! > previouslyLatestNotification?.created_at ?: Instant.MIN
) {
// Add to db
val filteredNewNotifications: List<Notification> = newNotifications.filter {
it.created_at ?: Instant.MIN > previouslyLatestNotification?.created_at ?: Instant.MIN
}.map {
it.copy(user_id = user.user_id, instance_uri = user.instance_uri)
}.sortedBy { it.created_at }
db.notificationDao().insertAll(filteredNewNotifications)
// Launch new notifications
filteredNewNotifications.forEach {
showNotification(it, user, uniqueUserId)
}
previouslyLatestNotification =
filteredNewNotifications.maxByOrNull { it.created_at ?: Instant.MIN }
// Request again
newNotifications = api.notifications(
since_id = previouslyLatestNotification?.id
)
}
} catch (exception: IOException) {
return Result.failure()
} catch (exception: HttpException) {
return Result.failure()
}
}
return Result.success()
}
private fun showNotification(
notification: Notification,
user: UserDatabaseEntity,
uniqueUserId: String
) {
val intent: Intent = when (notification.type) {
mention -> notification.status?.let {
Intent(applicationContext, PostActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
putExtra(Status.POST_TAG, notification.status)
putExtra(Status.VIEW_COMMENTS_TAG, true)
}
} ?: Intent(applicationContext, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
putExtra(SHOW_NOTIFICATION_TAG, true)
}
else -> Intent(applicationContext, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
putExtra(SHOW_NOTIFICATION_TAG, true)
}
}.putExtra(USER_NOTIFICATION_TAG, user.user_id)
.putExtra(INSTANCE_NOTIFICATION_TAG, user.instance_uri)
val builder = NotificationCompat.Builder(applicationContext, makeChannelId(uniqueUserId, notification.type))
.setSmallIcon(
when (notification.type) {
follow -> R.drawable.ic_follow
mention -> R.drawable.mention_at_24dp
reblog -> R.drawable.ic_reblog
favourite -> R.drawable.ic_like_full
comment -> R.drawable.ic_comment_empty
poll -> R.drawable.poll
null -> R.drawable.ic_comment_empty
}
)
.setColor(Color.parseColor("#6200EE"))
.setContentTitle(
notification.account?.username?.let { username ->
applicationContext.getString(
when (notification.type) {
follow -> R.string.followed_notification
comment -> R.string.comment_notification
mention -> R.string.mention_notification
reblog -> R.string.shared_notification
favourite -> R.string.liked_notification
poll -> R.string.poll_notification
null -> R.string.other_notification
}
).format(username)
}
)
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
// Set the intent that will fire when the user taps the notification
.setContentIntent(
PendingIntent.getActivity(applicationContext, 0, intent, PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT)
)
.setAutoCancel(true)
if (notification.type == mention || notification.type == comment || notification.type == poll){
builder.setContentText(notification.status?.content)
}
builder.setGroup(uniqueUserId)
with(NotificationManagerCompat.from(applicationContext)) {
// notificationId is a unique int for each notification
notify((uniqueUserId + notification.id).hashCode(), builder.build())
}
}
companion object {
const val SHOW_NOTIFICATION_TAG = "org.pixeldroid.app.SHOW_NOTIFICATION"
const val INSTANCE_NOTIFICATION_TAG = "org.pixeldroid.app.USER_NOTIFICATION"
const val USER_NOTIFICATION_TAG = "org.pixeldroid.app.INSTANCE_NOTIFICATION"
const val otherNotificationType = "other"
}
}
fun makeNotificationChannels(context: Context, handle: String, channelGroupId: String): Boolean {
val notificationManager: NotificationManager =
context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
// Create the NotificationChannel, but only on API 26+ because
// the NotificationChannel class is new and not in the support library
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
// The id of the group, hashed (since when creating the group, it may be truncated if too long)
val hashedGroupId = channelGroupId.hashCode().toString()
notificationManager.createNotificationChannelGroup(NotificationChannelGroup(hashedGroupId, handle))
val importance = NotificationManager.IMPORTANCE_DEFAULT
val channels: List<NotificationChannel> = listOf(
NotificationChannel(makeChannelId(channelGroupId, follow), context.getString(R.string.followed_notification_channel), importance),
NotificationChannel(makeChannelId(channelGroupId, mention), context.getString(R.string.mention_notification_channel), importance),
NotificationChannel(makeChannelId(channelGroupId, reblog), context.getString(R.string.shared_notification_channel), importance),
NotificationChannel(makeChannelId(channelGroupId, favourite), context.getString(R.string.liked_notification_channel), importance),
NotificationChannel(makeChannelId(channelGroupId, comment), context.getString(R.string.comment_notification_channel), importance),
NotificationChannel(makeChannelId(channelGroupId, poll), context.getString(R.string.poll_notification_channel), importance),
NotificationChannel(makeChannelId(channelGroupId, null), context.getString(R.string.other_notification_channel), importance),
).map {
it.apply { group = hashedGroupId }
}
// Register the channels with the system
notificationManager.createNotificationChannels(channels)
//Return true if notifications are enabled, false if disabled
return notificationManager.areNotificationsEnabled() and
(if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
val channelGroup =
notificationManager.getNotificationChannelGroup(hashedGroupId)
!channelGroup.isBlocked
} else true) and
(if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
!notificationManager.areNotificationsPaused()
} else true) and
!channels.all {
notificationManager.getNotificationChannel(it.id).importance <= NotificationManager.IMPORTANCE_NONE
}
}
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
notificationManager.areNotificationsEnabled()
} else {
true
}
}
/**
* [channelGroupId] is the id used to uniquely identify the group: for us it is a unique id
* identifying a user consisting of the concatenation of the instance uri and user id.
*/
private fun makeChannelId(channelGroupId: String, type: Notification.NotificationType?): String =
(channelGroupId + (type ?: NotificationsWorker.otherNotificationType)).hashCode().toString()
fun makeChannelGroupId(user: UserDatabaseEntity) = user.instance_uri + user.user_id
fun removeNotificationChannelsFromAccount(context: Context, user: UserDatabaseEntity?) = user?.let {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val notificationManager: NotificationManager =
context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
val channelGroupId = makeChannelGroupId(user)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
notificationManager.deleteNotificationChannelGroup(channelGroupId.hashCode().toString())
} else {
val types: MutableList<Notification.NotificationType?> =
Notification.NotificationType.values().toMutableList()
types += null
types.forEach {
notificationManager.deleteNotificationChannel(makeChannelId(channelGroupId, it))
}
}
}
}

View File

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@color/colorDrawing"
android:pathData="M7.58,4.08L6.15,2.65C3.75,4.48 2.17,7.3 2.03,10.5h2c0.15,-2.65 1.51,-4.97 3.55,-6.42zM19.97,10.5h2c-0.15,-3.2 -1.73,-6.02 -4.12,-7.85l-1.42,1.43c2.02,1.45 3.39,3.77 3.54,6.42zM18,11c0,-3.07 -1.64,-5.64 -4.5,-6.32L13.5,4c0,-0.83 -0.67,-1.5 -1.5,-1.5s-1.5,0.67 -1.5,1.5v0.68C7.63,5.36 6,7.92 6,11v5l-2,2v1h16v-1l-2,-2v-5zM12,22c0.14,0 0.27,-0.01 0.4,-0.04 0.65,-0.14 1.18,-0.58 1.44,-1.18 0.1,-0.24 0.15,-0.5 0.15,-0.78h-4c0.01,1.1 0.9,2 2.01,2z"/>
</vector>

View File

@ -47,15 +47,25 @@
<!-- Notifications: like (favourite) notification -->
<string name="liked_notification">%1$s liked your post</string>
<!-- Notifications: end of poll notification -->
<string name="poll_notification">"%1$s's poll has ended"</string>
<!-- Notifications: comment notification -->
<string name="comment_notification">%1$s commented on your post</string>
<!-- Notifications: end of poll notification -->
<string name="poll_notification">"%1$s's poll has ended"</string>
<!-- Notifications: other notification -->
<string name="other_notification">"Notification from %1$s"</string>
<string name="followed_notification_channel">"New followers"</string>
<string name="mention_notification_channel">"Mentions"</string>
<string name="shared_notification_channel">"Shares"</string>
<string name="liked_notification_channel">"Likes"</string>
<string name="comment_notification_channel">"Comments"</string>
<string name="poll_notification_channel">"Polls"</string>
<string name="other_notification_channel">"Other"</string>
<!-- Login page -->
<string name="whats_an_instance">"What's an instance?"</string>
<string name="whats_an_instance_explanation">"You might be confused by the text field asking for the domain of your 'instance'.
@ -235,4 +245,7 @@ For more info about Pixelfed, you can check here: https://pixelfed.org"</string>
<!-- Error message when a selected file can not be found -->
<string name="file_not_found">File %1$s was not found</string>
<string name="notifications_settings">Notification settings</string>
<string name="notifications_settings_summary">Manage what notifications you want to receive</string>
<string name="login_notifications">Couldn\'t fetch latest notifications</string>
</resources>

View File

@ -21,6 +21,16 @@
app:useSimpleSummaryProvider="true"
app:icon="@drawable/translate_black_24dp" />
<Preference android:title="@string/notifications_settings"
android:key="notification"
android:summary="@string/notifications_settings_summary"
app:icon="@drawable/ic_baseline_notifications_active_24">
<intent android:action="android.settings.APP_NOTIFICATION_SETTINGS">
<extra android:name="android.provider.extra.APP_PACKAGE"
android:value="@string/application_id" />
</intent>
</Preference>
<Preference android:title="@string/about"
android:key="about"
android:summary="@string/about_pixeldroid"

View File

@ -8,58 +8,15 @@ import kotlinx.coroutines.runBlocking
import org.junit.Assert.assertEquals
import org.junit.Rule
import org.junit.Test
import java.text.SimpleDateFormat
import java.time.Instant
/**
* Example local unit test, which will execute on the development machine (host).
* Unit test, which will execute on the development machine (host).
*
* See [testing documentation](http://d.android.com/tools/testing).
*/
class APIUnitTest {
private val referenceFirstStatus = Status(id="140364967936397312", uri="https://pixelfed.de/p/Miike/140364967936397312",
created_at= SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.Z").parse("2020-03-03T08:00:16.+0000"),
account=Account(id="115114166443970560", username="Miike", acct="Miike",
url="https://pixelfed.de/Miike", display_name="Miike Duart", note="",
avatar="https://pixelfed.de/storage/avatars/011/511/416/644/397/056/0/ZhaopLJWTWJ3hsVCS5pS_avatar.png?v=d4735e3a265e16eee03f59718b9b5d03019c07d8b6c51f90da3a666eec13ab35",
avatar_static="https://pixelfed.de/storage/avatars/011/511/416/644/397/056/0/ZhaopLJWTWJ3hsVCS5pS_avatar.png?v=d4735e3a265e16eee03f59718b9b5d03019c07d8b6c51f90da3a666eec13ab35",
header="", header_static="", locked=false, emojis= emptyList(), discoverable=null,
created_at="2019-12-24T15:42:35.000000Z", statuses_count=71, followers_count=14,
following_count=0, moved=null, fields=null, bot=false, source=null),
content="""Day 8 <a href="https://pixelfed.de/discover/tags/rotavicentina?src=hash" title="#rotavicentina" class="u-url hashtag" rel="external nofollow noopener">#rotavicentina</a> <a href="https://pixelfed.de/discover/tags/hiking?src=hash" title="#hiking" class="u-url hashtag" rel="external nofollow noopener">#hiking</a> <a href="https://pixelfed.de/discover/tags/nature?src=hash" title="#nature" class="u-url hashtag" rel="external nofollow noopener">#nature</a>""",
visibility=Status.Visibility.public, sensitive=false, spoiler_text="",
media_attachments= listOf(Attachment(id="15888", type= Attachment.AttachmentType.image, url="https://pixelfed.de/storage/m/113a3e2124a33b1f5511e531953f5ee48456e0c7/34dd6d9fb1762dac8c7ddeeaf789d2d8fa083c9f/JtjO0eAbELpgO1UZqF5ydrKbCKRVyJUM1WAaqIeB.jpeg",
preview_url="https://pixelfed.de/storage/m/113a3e2124a33b1f5511e531953f5ee48456e0c7/34dd6d9fb1762dac8c7ddeeaf789d2d8fa083c9f/JtjO0eAbELpgO1UZqF5ydrKbCKRVyJUM1WAaqIeB_thumb.jpeg",
remote_url=null, text_url=null, description=null, blurhash=null, meta = null)),
application= Application(name="web", website=null, vapid_key=null), mentions=emptyList(),
tags= listOf(Tag(name="hiking", url="https://pixelfed.de/discover/tags/hiking", history=null), Tag(name="nature", url="https://pixelfed.de/discover/tags/nature", history=null), Tag(name="rotavicentina", url="https://pixelfed.de/discover/tags/rotavicentina", history=null)),
emojis= emptyList(), reblogs_count=0, favourites_count=0, replies_count=0, url="https://pixelfed.de/p/Miike/140364967936397312",
in_reply_to_id=null, in_reply_to_account=null, reblog=null, poll=null, card=null, language=null, text=null, favourited=null, reblogged=null, muted=null, bookmarked=null, pinned=null)
val sampleNotification = Notification("45723", Notification.NotificationType.favourite,
SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss+hh:mm").parse("2020-03-14T15:01:49+00:00")!!,
Account("79574199701737472", "Spaziergaenger",
"Spaziergaenger", "https://pixelfed.de/Spaziergaenger",
"anonymous", "", "https://pixelfed.de/storage/avatars/007/957/419/970/173/747/2/KEg4YgCgsmzdgyVztszz_avatar.jpeg?v=d4735e3a265e16eee03f59718b9b5d03019c07d8b6c51f90da3a666eec13ab35",
"https://pixelfed.de/storage/avatars/007/957/419/970/173/747/2/KEg4YgCgsmzdgyVztszz_avatar.jpeg?v=d4735e3a265e16eee03f59718b9b5d03019c07d8b6c51f90da3a666eec13ab35",
locked=false, followers_count = 40, following_count = 0, statuses_count = 891, created_at = "1568728767", header = "", discoverable = true, emojis = emptyList(), header_static = ""),
Status("144456497894658048","https://pixelfed.de/p/dante/144456497894658048",
SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.hhmmss'Z'").parse("2020-03-03T08:00:16.000000Z"), in_reply_to_id = null,
in_reply_to_account = null, reblog = null,content = "Saturn V launch", emojis = emptyList(), reblogs_count = 0,
favourites_count = 1, reblogged = false, favourited = false, muted = false, sensitive = false,
spoiler_text = "", visibility = Status.Visibility.public, application = Application("web", null),
language = null, pinned = false, mentions = emptyList(), tags = emptyList(), replies_count = 0,
account = Account("136453537340198912", "dante", "dante", locked = false, following_count = 3,
followers_count = 1,statuses_count = 1, note = "", url = "https://pixelfed.de/dante",
avatar = "https://pixelfed.de/storage/avatars/default.png?v=5feceb66ffc86f38d952786c6d696c79c2dbc239dd4e91b46729d73a27fb57e9",
emojis = emptyList(), header_static = "", header = "", created_at = "1582289858", avatar_static = "https://pixelfed.de/storage/avatars/default.png?v=5feceb66ffc86f38d952786c6d696c79c2dbc239dd4e91b46729d73a27fb57e9",
discoverable = true, display_name = "Dante"), media_attachments = listOf(
Attachment("16583",Attachment.AttachmentType.image, "https://pixelfed.de/storage/m/113a3e2124a33b1f5511e531953f5ee48456e0c7/0fa8bbe19cc23442034913a7c97fbe4527c1d63a/vs2vouJ86OvzxhK9ewhPlfPf4Y9IoQ5CHfiBIqad.jpeg",
"https://pixelfed.de/storage/m/113a3e2124a33b1f5511e531953f5ee48456e0c7/0fa8bbe19cc23442034913a7c97fbe4527c1d63a/vs2vouJ86OvzxhK9ewhPlfPf4Y9IoQ5CHfiBIqad_thumb.jpeg",
null, null, null, null)
)
, bookmarked = false, card = null, poll = null, text= null,url= "https://pixelfed.de/p/dante/144456497894658048")
, user_id = "", instance_uri = ""
)
@get:Rule
var wireMockRule = WireMockRule(8089)
@ -163,12 +120,12 @@ fun assertStatusEqualsToReference(actual: Status){
assert(
((actual.id=="140364967936397312"
&& actual.uri=="https://pixelfed.de/p/Miike/140364967936397312"
&& actual.created_at==SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.Z").parse("2020-03-03T08:00:16.+0000")
&& actual.created_at == Instant.parse("2020-03-03T08:00:16.+00:00")
&& actual.account!!.id=="115114166443970560"&& actual.account!!.username=="Miike"&& actual.account!!.acct=="Miike" &&
actual.account!!.url=="https://pixelfed.de/Miike"&& actual.account!!.display_name=="Miike Duart"&& actual.account!!.note==""&&
//actual.account!!.avatar=="https://pixelfed.de/storage/avatars/011/511/416/644/397/056/0/ZhaopLJWTWJ3hsVCS5pS_avatar.png?v=d4735e3a265e16eee03f59718b9b5d03019c07d8b6c51f90da3a666eec13ab35"&&
//actual.account!!.avatar_static=="https://pixelfed.de/storage/avatars/011/511/416/644/397/056/0/ZhaopLJWTWJ3hsVCS5pS_avatar.png?v=d4735e3a265e16eee03f59718b9b5d03019c07d8b6c51f90da3a666eec13ab35"&&
actual.account!!.header==""&& actual.account!!.header_static=="") && !actual.account!!.locked!! && actual.account!!.emojis== emptyList<Emoji>() && actual.account!!.discoverable == null && actual.account!!.created_at=="2019-12-24T15:42:35.000000Z" && actual.account!!.statuses_count==71 && actual.account!!.followers_count==14 && actual.account!!.following_count==0 && actual.account!!.moved==null && actual.account!!.fields==null && !actual.account!!.bot!! && actual.account!!.source==null && actual.content == """Day 8 <a href="https://pixelfed.de/discover/tags/rotavicentina?src=hash" title="#rotavicentina" class="u-url hashtag" rel="external nofollow noopener">#rotavicentina</a> <a href="https://pixelfed.de/discover/tags/hiking?src=hash" title="#hiking" class="u-url hashtag" rel="external nofollow noopener">#hiking</a> <a href="https://pixelfed.de/discover/tags/nature?src=hash" title="#nature" class="u-url hashtag" rel="external nofollow noopener">#nature</a>""" && actual.visibility==Status.Visibility.public) && !actual.sensitive!! && actual.spoiler_text==""
actual.account!!.header==""&& actual.account!!.header_static=="") && !actual.account!!.locked!! && actual.account!!.emojis== emptyList<Emoji>() && actual.account!!.discoverable == null && actual.account!!.created_at==Instant.parse("2019-12-24T15:42:35.000000Z") && actual.account!!.statuses_count==71 && actual.account!!.followers_count==14 && actual.account!!.following_count==0 && actual.account!!.moved==null && actual.account!!.fields==null && !actual.account!!.bot!! && actual.account!!.source==null && actual.content == """Day 8 <a href="https://pixelfed.de/discover/tags/rotavicentina?src=hash" title="#rotavicentina" class="u-url hashtag" rel="external nofollow noopener">#rotavicentina</a> <a href="https://pixelfed.de/discover/tags/hiking?src=hash" title="#hiking" class="u-url hashtag" rel="external nofollow noopener">#hiking</a> <a href="https://pixelfed.de/discover/tags/nature?src=hash" title="#nature" class="u-url hashtag" rel="external nofollow noopener">#nature</a>""" && actual.visibility==Status.Visibility.public) && !actual.sensitive!! && actual.spoiler_text==""
)
val attchmnt = actual.media_attachments!![0]
assert(attchmnt.id == "15888" && attchmnt.type == Attachment.AttachmentType.image && attchmnt.url=="https://pixelfed.de/storage/m/113a3e2124a33b1f5511e531953f5ee48456e0c7/34dd6d9fb1762dac8c7ddeeaf789d2d8fa083c9f/JtjO0eAbELpgO1UZqF5ydrKbCKRVyJUM1WAaqIeB.jpeg" &&

View File

@ -1,19 +1,19 @@
package org.pixeldroid.app
import org.pixeldroid.app.utils.api.objects.*
import org.junit.Assert
import org.junit.Test
import java.text.SimpleDateFormat
import org.pixeldroid.app.utils.api.objects.*
import java.time.Instant
class PostUnitTest {
private val status = Status(id="140364967936397312", uri="https://pixelfed.de/p/Miike/140364967936397312",
created_at= SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.hhmmss'Z'").parse("2020-03-03T08:00:16.000000Z"),
created_at= Instant.parse("2020-03-03T08:00:16+00:00"),
account= Account(id="115114166443970560", username="Miike", acct="Miike",
url="https://pixelfed.de/Miike", display_name="Miike Duart", note="",
avatar="https://pixelfed.de/storage/avatars/011/511/416/644/397/056/0/ZhaopLJWTWJ3hsVCS5pS_avatar.png?v=d4735e3a265e16eee03f59718b9b5d03019c07d8b6c51f90da3a666eec13ab35",
avatar_static="https://pixelfed.de/storage/avatars/011/511/416/644/397/056/0/ZhaopLJWTWJ3hsVCS5pS_avatar.png?v=d4735e3a265e16eee03f59718b9b5d03019c07d8b6c51f90da3a666eec13ab35",
header="", header_static="", locked=false, emojis= emptyList(), discoverable=false,
created_at="2019-12-24T15:42:35.000000Z", statuses_count=71, followers_count=14,
created_at=Instant.parse("2019-12-24T15:42:35.000000Z"), statuses_count=71, followers_count=14,
following_count=0, moved=null, fields=null, bot=false, source=null),
content="""Day 8 <a href="https://pixelfed.de/discover/tags/rotavicentina?src=hash" title="#rotavicentina" class="u-url hashtag" rel="external nofollow noopener">#rotavicentina</a> <a href="https://pixelfed.de/discover/tags/hiking?src=hash" title="#hiking" class="u-url hashtag" rel="external nofollow noopener">#hiking</a> <a href="https://pixelfed.de/discover/tags/nature?src=hash" title="#nature" class="u-url hashtag" rel="external nofollow noopener">#nature</a>""",
visibility=Status.Visibility.public, sensitive=false, spoiler_text="",

View File

@ -1,7 +1,7 @@
#Tue Apr 20 12:38:34 CEST 2021
#Sat Sep 18 21:24:34 CEST 2021
distributionBase=GRADLE_USER_HOME
distributionUrl=https\://services.gradle.org/distributions/gradle-7.2-bin.zip
distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-7.0.2-all.zip
distributionSha256Sum=13bf8d3cf8eeeb5770d19741a59bde9bd966dd78d17f1bbad787a05ef19d1c2d
zipStoreBase=GRADLE_USER_HOME
distributionSha256Sum=f581709a9c35e9cb92e16f585d2c4bc99b2b1a5f85d2badbd3dc6bff59e1e6dd