diff --git a/app/build.gradle b/app/build.gradle index d648bf20..15dba25f 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -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' diff --git a/app/licenses.yml b/app/licenses.yml index 954bc667..c525c009 100644 --- a/app/licenses.yml +++ b/app/licenses.yml @@ -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 diff --git a/app/src/androidTest/java/org/pixeldroid/app/IntentTest.kt b/app/src/androidTest/java/org/pixeldroid/app/IntentTest.kt index 7b29e396..6f312031 100644 --- a/app/src/androidTest/java/org/pixeldroid/app/IntentTest.kt +++ b/app/src/androidTest/java/org/pixeldroid/app/IntentTest.kt @@ -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 = CoreMatchers.allOf( IntentMatchers.hasExtra(ACCOUNT_TAG, account) ) - + "2021-02-11T23:44:03.000000Z" waitForView(R.id.description) //Click the mention diff --git a/app/src/androidTest/java/org/pixeldroid/app/MockedServerTest.kt b/app/src/androidTest/java/org/pixeldroid/app/MockedServerTest.kt index 663e5e9c..fc6fea22 100644 --- a/app/src/androidTest/java/org/pixeldroid/app/MockedServerTest.kt +++ b/app/src/androidTest/java/org/pixeldroid/app/MockedServerTest.kt @@ -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 diff --git a/app/src/androidTest/java/org/pixeldroid/app/NotificationWorkerTest.kt b/app/src/androidTest/java/org/pixeldroid/app/NotificationWorkerTest.kt new file mode 100644 index 00000000..25f0c43a --- /dev/null +++ b/app/src/androidTest/java/org/pixeldroid/app/NotificationWorkerTest.kt @@ -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 + 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(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))) + + } +} \ No newline at end of file diff --git a/app/src/androidTest/java/org/pixeldroid/app/PostTest.kt b/app/src/androidTest/java/org/pixeldroid/app/PostTest.kt index 5e833694..134390a9 100644 --- a/app/src/androidTest/java/org/pixeldroid/app/PostTest.kt +++ b/app/src/androidTest/java/org/pixeldroid/app/PostTest.kt @@ -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 #rotavicentina #hiking #nature""", 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 #rotavicentina #hiking #nature""", visibility=Status.Visibility.public, sensitive=false, spoiler_text="", diff --git a/app/src/androidTest/java/org/pixeldroid/app/ProfileTest.kt b/app/src/androidTest/java/org/pixeldroid/app/ProfileTest.kt index 778a3946..6eb60830 100644 --- a/app/src/androidTest/java/org/pixeldroid/app/ProfileTest.kt +++ b/app/src/androidTest/java/org/pixeldroid/app/ProfileTest.kt @@ -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) diff --git a/app/src/main/java/org/pixeldroid/app/LoginActivity.kt b/app/src/main/java/org/pixeldroid/app/LoginActivity.kt index 2b125638..a97c89ff 100644 --- a/app/src/main/java/org/pixeldroid/app/LoginActivity.kt +++ b/app/src/main/java/org/pixeldroid/app/LoginActivity.kt @@ -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) + ) } } diff --git a/app/src/main/java/org/pixeldroid/app/MainActivity.kt b/app/src/main/java/org/pixeldroid/app/MainActivity.kt index 8a981c20..62444f56 100644 --- a/app/src/main/java/org/pixeldroid/app/MainActivity.kt +++ b/app/src/main/java/org/pixeldroid/app/MainActivity.kt @@ -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() diff --git a/app/src/main/java/org/pixeldroid/app/postCreation/PostCreationActivity.kt b/app/src/main/java/org/pixeldroid/app/postCreation/PostCreationActivity.kt index 8d7ba739..1e20cc97 100644 --- a/app/src/main/java/org/pixeldroid/app/postCreation/PostCreationActivity.kt +++ b/app/src/main/java/org/pixeldroid/app/postCreation/PostCreationActivity.kt @@ -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 diff --git a/app/src/main/java/org/pixeldroid/app/postCreation/ProgressRequestBody.kt b/app/src/main/java/org/pixeldroid/app/postCreation/ProgressRequestBody.kt index 3893b754..499d1733 100644 --- a/app/src/main/java/org/pixeldroid/app/postCreation/ProgressRequestBody.kt +++ b/app/src/main/java/org/pixeldroid/app/postCreation/ProgressRequestBody.kt @@ -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 diff --git a/app/src/main/java/org/pixeldroid/app/posts/HtmlUtils.kt b/app/src/main/java/org/pixeldroid/app/posts/HtmlUtils.kt index 3af59d1a..a18e624a 100644 --- a/app/src/main/java/org/pixeldroid/app/posts/HtmlUtils.kt +++ b/app/src/main/java/org/pixeldroid/app/posts/HtmlUtils.kt @@ -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() diff --git a/app/src/main/java/org/pixeldroid/app/settings/SettingsActivity.kt b/app/src/main/java/org/pixeldroid/app/settings/SettingsActivity.kt index bc005412..53f97023 100644 --- a/app/src/main/java/org/pixeldroid/app/settings/SettingsActivity.kt +++ b/app/src/main/java/org/pixeldroid/app/settings/SettingsActivity.kt @@ -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")) + } } } diff --git a/app/src/main/java/org/pixeldroid/app/utils/api/PixelfedAPI.kt b/app/src/main/java/org/pixeldroid/app/utils/api/PixelfedAPI.kt index 642e0624..5c9ce96b 100644 --- a/app/src/main/java/org/pixeldroid/app/utils/api/PixelfedAPI.kt +++ b/app/src/main/java/org/pixeldroid/app/utils/api/PixelfedAPI.kt @@ -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).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( diff --git a/app/src/main/java/org/pixeldroid/app/utils/api/objects/Account.kt b/app/src/main/java/org/pixeldroid/app/utils/api/objects/Account.kt index 11073f05..182a5a06 100644 --- a/app/src/main/java/org/pixeldroid/app/utils/api/objects/Account.kt +++ b/app/src/main/java/org/pixeldroid/app/utils/api/objects/Account.kt @@ -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? = 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, diff --git a/app/src/main/java/org/pixeldroid/app/utils/api/objects/Notification.kt b/app/src/main/java/org/pixeldroid/app/utils/api/objects/Notification.kt index 94b73733..05b48ed4 100644 --- a/app/src/main/java/org/pixeldroid/app/utils/api/objects/Notification.kt +++ b/app/src/main/java/org/pixeldroid/app/utils/api/objects/Notification.kt @@ -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, diff --git a/app/src/main/java/org/pixeldroid/app/utils/api/objects/Poll.kt b/app/src/main/java/org/pixeldroid/app/utils/api/objects/Poll.kt index 264b9c21..69df2f7e 100644 --- a/app/src/main/java/org/pixeldroid/app/utils/api/objects/Poll.kt +++ b/app/src/main/java/org/pixeldroid/app/utils/api/objects/Poll.kt @@ -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?, + val options: List?, + val emojis: List? + ): Serializable { + data class Option( + val title: String?, + val votes_count: Int? //null if result not published yet + ) + } diff --git a/app/src/main/java/org/pixeldroid/app/utils/api/objects/Status.kt b/app/src/main/java/org/pixeldroid/app/utils/api/objects/Status.kt index 707873f2..847997cb 100644 --- a/app/src/main/java/org/pixeldroid/app/utils/api/objects/Status.kt +++ b/app/src/main/java/org/pixeldroid/app/utils/api/objects/Status.kt @@ -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, diff --git a/app/src/main/java/org/pixeldroid/app/utils/db/AppDatabase.kt b/app/src/main/java/org/pixeldroid/app/utils/db/AppDatabase.kt index 88bfea96..dc3f7b4e 100644 --- a/app/src/main/java/org/pixeldroid/app/utils/db/AppDatabase.kt +++ b/app/src/main/java/org/pixeldroid/app/utils/db/AppDatabase.kt @@ -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() { diff --git a/app/src/main/java/org/pixeldroid/app/utils/db/Converters.kt b/app/src/main/java/org/pixeldroid/app/utils/db/Converters.kt index a3f42bfb..71d9d32e 100644 --- a/app/src/main/java/org/pixeldroid/app/utils/db/Converters.kt +++ b/app/src/main/java/org/pixeldroid/app/utils/db/Converters.kt @@ -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 = gson.toJson(list) @@ -16,12 +32,6 @@ class Converters { fun jsonToList(json: String): List = gson.fromJson(json, Array::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) diff --git a/app/src/main/java/org/pixeldroid/app/utils/db/dao/UserDao.kt b/app/src/main/java/org/pixeldroid/app/utils/db/dao/UserDao.kt index b1a696e3..1d0c37a1 100644 --- a/app/src/main/java/org/pixeldroid/app/utils/db/dao/UserDao.kt +++ b/app/src/main/java/org/pixeldroid/app/utils/db/dao/UserDao.kt @@ -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 } \ No newline at end of file diff --git a/app/src/main/java/org/pixeldroid/app/utils/db/dao/feedContent/NotificationDao.kt b/app/src/main/java/org/pixeldroid/app/utils/db/dao/feedContent/NotificationDao.kt index 00d93218..0c498190 100644 --- a/app/src/main/java/org/pixeldroid/app/utils/db/dao/feedContent/NotificationDao.kt +++ b/app/src/main/java/org/pixeldroid/app/utils/db/dao/feedContent/NotificationDao.kt @@ -12,9 +12,13 @@ interface NotificationDao: FeedContentDao { 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 + @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) } \ No newline at end of file diff --git a/app/src/main/java/org/pixeldroid/app/utils/db/dao/feedContent/posts/HomePostDao.kt b/app/src/main/java/org/pixeldroid/app/utils/db/dao/feedContent/posts/HomePostDao.kt index 295d0b2a..e42789d7 100644 --- a/app/src/main/java/org/pixeldroid/app/utils/db/dao/feedContent/posts/HomePostDao.kt +++ b/app/src/main/java/org/pixeldroid/app/utils/db/dao/feedContent/posts/HomePostDao.kt @@ -9,7 +9,7 @@ import org.pixeldroid.app.utils.db.entities.HomeStatusDatabaseEntity @Dao interface HomePostDao: FeedContentDao { @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 @Query("DELETE FROM homePosts WHERE user_id=:userId AND instance_uri=:instanceUri") diff --git a/app/src/main/java/org/pixeldroid/app/utils/db/dao/feedContent/posts/PublicPostDao.kt b/app/src/main/java/org/pixeldroid/app/utils/db/dao/feedContent/posts/PublicPostDao.kt index 8335628b..9a807ba7 100644 --- a/app/src/main/java/org/pixeldroid/app/utils/db/dao/feedContent/posts/PublicPostDao.kt +++ b/app/src/main/java/org/pixeldroid/app/utils/db/dao/feedContent/posts/PublicPostDao.kt @@ -9,7 +9,7 @@ import org.pixeldroid.app.utils.db.entities.PublicFeedStatusDatabaseEntity @Dao interface PublicPostDao: FeedContentDao { @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 @Query("DELETE FROM publicPosts WHERE user_id=:userId AND instance_uri=:instanceUri") diff --git a/app/src/main/java/org/pixeldroid/app/utils/db/entities/HomeStatusDatabaseEntity.kt b/app/src/main/java/org/pixeldroid/app/utils/db/entities/HomeStatusDatabaseEntity.kt index 42d0653d..ed9ea836 100644 --- a/app/src/main/java/org/pixeldroid/app/utils/db/entities/HomeStatusDatabaseEntity.kt +++ b/app/src/main/java/org/pixeldroid/app/utils/db/entities/HomeStatusDatabaseEntity.kt @@ -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, diff --git a/app/src/main/java/org/pixeldroid/app/utils/db/entities/PublicFeedStatusDatabaseEntity.kt b/app/src/main/java/org/pixeldroid/app/utils/db/entities/PublicFeedStatusDatabaseEntity.kt index 9986d601..9c3dbe1b 100644 --- a/app/src/main/java/org/pixeldroid/app/utils/db/entities/PublicFeedStatusDatabaseEntity.kt +++ b/app/src/main/java/org/pixeldroid/app/utils/db/entities/PublicFeedStatusDatabaseEntity.kt @@ -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, diff --git a/app/src/main/java/org/pixeldroid/app/utils/db/entities/UserDatabaseEntity.kt b/app/src/main/java/org/pixeldroid/app/utils/db/entities/UserDatabaseEntity.kt index 54725b4d..6aa92cdb 100644 --- a/app/src/main/java/org/pixeldroid/app/utils/db/entities/UserDatabaseEntity.kt +++ b/app/src/main/java/org/pixeldroid/app/utils/db/entities/UserDatabaseEntity.kt @@ -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 -) \ No newline at end of file +): Serializable { + val fullHandle: String + get() = "@${username}@${instance_uri.removePrefix("https://")}" +} \ No newline at end of file diff --git a/app/src/main/java/org/pixeldroid/app/utils/di/ApplicationComponent.kt b/app/src/main/java/org/pixeldroid/app/utils/di/ApplicationComponent.kt index 3fdff9b7..3a59eec0 100644 --- a/app/src/main/java/org/pixeldroid/app/utils/di/ApplicationComponent.kt +++ b/app/src/main/java/org/pixeldroid/app/utils/di/ApplicationComponent.kt @@ -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? diff --git a/app/src/main/java/org/pixeldroid/app/utils/notificationsWorker/NotificationsFetcher.kt b/app/src/main/java/org/pixeldroid/app/utils/notificationsWorker/NotificationsFetcher.kt new file mode 100644 index 00000000..53c8f88a --- /dev/null +++ b/app/src/main/java/org/pixeldroid/app/utils/notificationsWorker/NotificationsFetcher.kt @@ -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( + 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) +} diff --git a/app/src/main/java/org/pixeldroid/app/utils/notificationsWorker/NotificationsWorker.kt b/app/src/main/java/org/pixeldroid/app/utils/notificationsWorker/NotificationsWorker.kt new file mode 100644 index 00000000..ddac7f4c --- /dev/null +++ b/app/src/main/java/org/pixeldroid/app/utils/notificationsWorker/NotificationsWorker.kt @@ -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 = 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? = 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 = 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 = 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.values().toMutableList() + types += null + + types.forEach { + notificationManager.deleteNotificationChannel(makeChannelId(channelGroupId, it)) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_baseline_notifications_active_24.xml b/app/src/main/res/drawable/ic_baseline_notifications_active_24.xml new file mode 100644 index 00000000..13a7aeeb --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_notifications_active_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 65c565cf..328a6d2b 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -47,15 +47,25 @@ %1$s liked your post - - "%1$s's poll has ended" - %1$s commented on your post + + "%1$s's poll has ended" + "Notification from %1$s" + "New followers" + "Mentions" + "Shares" + "Likes" + "Comments" + "Polls" + "Other" + + + "What's an instance?" "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" File %1$s was not found + Notification settings + Manage what notifications you want to receive + Couldn\'t fetch latest notifications \ No newline at end of file diff --git a/app/src/main/res/xml/root_preferences.xml b/app/src/main/res/xml/root_preferences.xml index 298a62a6..fe53aa91 100644 --- a/app/src/main/res/xml/root_preferences.xml +++ b/app/src/main/res/xml/root_preferences.xml @@ -21,6 +21,16 @@ app:useSimpleSummaryProvider="true" app:icon="@drawable/translate_black_24dp" /> + + + + + + #rotavicentina #hiking #nature""", - 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() && 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 #rotavicentina #hiking #nature""" && actual.visibility==Status.Visibility.public) && !actual.sensitive!! && actual.spoiler_text=="" + actual.account!!.header==""&& actual.account!!.header_static=="") && !actual.account!!.locked!! && actual.account!!.emojis== emptyList() && 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 #rotavicentina #hiking #nature""" && 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" && diff --git a/app/src/test/java/org/pixeldroid/app/PostUnitTest.kt b/app/src/test/java/org/pixeldroid/app/PostUnitTest.kt index c9a89937..0978fda4 100644 --- a/app/src/test/java/org/pixeldroid/app/PostUnitTest.kt +++ b/app/src/test/java/org/pixeldroid/app/PostUnitTest.kt @@ -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 #rotavicentina #hiking #nature""", visibility=Status.Visibility.public, sensitive=false, spoiler_text="", diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index a880ee63..faf868a7 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -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