Merge branch 'master' into swap_username

This commit is contained in:
Matthieu 2021-09-25 21:44:51 +02:00
commit f6e4804c77
61 changed files with 1088 additions and 336 deletions

View File

@ -20,6 +20,10 @@ Builds:
versionCode: ${versionCode}
commit: HEAD
subdir: app
sudo:
- apt-get update || apt-get update
- apt-get install -y openjdk-11-jdk
- update-alternatives --auto java
gradle:
- yes

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
}
@ -27,8 +28,8 @@ android {
applicationId "org.pixeldroid.app"
minSdkVersion 23
targetSdkVersion 30
versionCode 3
versionName "1.0.beta3"
versionCode 4
versionName "1.0.beta4"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
testInstrumentationRunnerArguments clearPackageData: 'true'
@ -64,12 +65,12 @@ android {
localProperties.load(new FileInputStream(rootProject.file("local.properties")))
}
buildConfigField "String", "USER_ID", System.getenv("USER_ID") ?: localProperties['USER_ID'] ?: ""
buildConfigField "String", "INSTANCE_URI", System.getenv("INSTANCE_URI") ?: localProperties['INSTANCE_URI'] ?: ""
buildConfigField "String", "ACCESS_TOKEN", System.getenv("ACCESS_TOKEN") ?: localProperties['ACCESS_TOKEN'] ?: ""
buildConfigField "String", "REFRESH_TOKEN", System.getenv("REFRESH_TOKEN") ?: localProperties['REFRESH_TOKEN'] ?: ""
buildConfigField "String", "CLIENT_ID", System.getenv("CLIENT_ID") ?: localProperties['CLIENT_ID'] ?: ""
buildConfigField "String", "CLIENT_SECRET", System.getenv("CLIENT_SECRET") ?: localProperties['CLIENT_SECRET'] ?: ""
buildConfigField "String", "USER_ID", System.getenv("USER_ID") ?: localProperties['USER_ID'] ?: '""'
buildConfigField "String", "INSTANCE_URI", System.getenv("INSTANCE_URI") ?: localProperties['INSTANCE_URI'] ?: '""'
buildConfigField "String", "ACCESS_TOKEN", System.getenv("ACCESS_TOKEN") ?: localProperties['ACCESS_TOKEN'] ?: '""'
buildConfigField "String", "REFRESH_TOKEN", System.getenv("REFRESH_TOKEN") ?: localProperties['REFRESH_TOKEN'] ?: '""'
buildConfigField "String", "CLIENT_ID", System.getenv("CLIENT_ID") ?: localProperties['CLIENT_ID'] ?: '""'
buildConfigField "String", "CLIENT_SECRET", System.getenv("CLIENT_SECRET") ?: localProperties['CLIENT_SECRET'] ?: '""'
}
release {
minifyEnabled true
@ -90,7 +91,7 @@ android {
}
buildFeatures {
viewBinding = true
viewBinding true
dataBinding = true
}
@ -100,13 +101,15 @@ android {
dependencies {
implementation fileTree(dir: 'libs', include: ['*.jar'])
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:1.1.5'
/**
* AndroidX dependencies:
*/
implementation 'androidx.appcompat:appcompat:1.3.0'
implementation 'androidx.core:core-ktx:1.5.0'
implementation 'androidx.appcompat:appcompat:1.3.1'
implementation 'androidx.core:core-ktx:1.6.0'
implementation 'androidx.preference:preference-ktx:1.1.1'
implementation 'androidx.constraintlayout:constraintlayout:2.0.4'
implementation 'androidx.constraintlayout:constraintlayout:2.1.1'
implementation 'androidx.navigation:navigation-fragment-ktx:2.3.5'
implementation 'androidx.navigation:navigation-ui-ktx:2.3.5'
implementation "androidx.browser:browser:1.3.0"
@ -114,25 +117,27 @@ dependencies {
implementation "androidx.swiperefreshlayout:swiperefreshlayout:1.1.0"
implementation 'androidx.navigation:navigation-fragment-ktx:2.3.5'
implementation 'androidx.navigation:navigation-ui-ktx:2.3.5'
implementation 'androidx.paging:paging-runtime-ktx:3.0.0'
implementation 'androidx.paging:paging-runtime-ktx:3.0.1'
implementation 'androidx.lifecycle:lifecycle-livedata-ktx:2.3.1'
implementation 'androidx.lifecycle:lifecycle-viewmodel-savedstate:2.3.1'
implementation "androidx.lifecycle:lifecycle-runtime-ktx:2.3.1"
implementation "androidx.lifecycle:lifecycle-common-java8:2.3.1"
implementation "androidx.annotation:annotation:1.2.0"
implementation 'androidx.gridlayout:gridlayout:1.0.0'
implementation "androidx.activity:activity-ktx:1.2.3"
implementation 'androidx.fragment:fragment-ktx:1.3.5'
implementation "androidx.activity:activity-ktx:1.3.1"
implementation 'androidx.fragment:fragment-ktx:1.3.6'
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.0'
def cameraX_version = '1.0.1'
implementation "androidx.camera:camera-core:${cameraX_version}"
implementation "androidx.camera:camera-camera2:${cameraX_version}"
// CameraX Lifecycle library
implementation "androidx.camera:camera-lifecycle:$cameraX_version"
// CameraX View class
implementation 'androidx.camera:camera-view:1.0.0-alpha25'
implementation 'androidx.camera:camera-view:1.0.0-alpha28'
def room_version = "2.3.0"
implementation "androidx.room:room-runtime:$room_version"
@ -145,21 +150,21 @@ dependencies {
*/
implementation 'com.google.android.material:material:1.3.0'
implementation 'com.google.android.material:material:1.4.0'
//Dagger (dependency injection)
implementation 'com.google.dagger:dagger-android:2.37'
implementation 'com.google.dagger:dagger-android-support:2.37'
implementation 'com.google.dagger:dagger-android:2.38.1'
implementation 'com.google.dagger:dagger-android-support:2.38.1'
// if you use the support libraries
kapt 'com.google.dagger:dagger-android-processor:2.37'
kapt 'com.google.dagger:dagger-compiler:2.37'
kapt 'com.google.dagger:dagger-android-processor:2.38.1'
kapt 'com.google.dagger:dagger-compiler:2.38.1'
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'
@ -179,18 +184,18 @@ dependencies {
implementation 'androidx.legacy:legacy-support-v4:1.0.0'
implementation 'com.mikepenz:materialdrawer:8.4.1'
implementation 'com.mikepenz:materialdrawer:8.4.3'
// Add for NavController support
implementation 'com.mikepenz:materialdrawer-nav:8.4.0'
implementation 'com.mikepenz:materialdrawer-nav:8.4.2'
//iconics
implementation "com.mikepenz:iconics-core:5.2.8"
implementation 'com.mikepenz:materialdrawer-iconics:8.4.1'
implementation "com.mikepenz:iconics-views:5.0.3"
implementation 'com.mikepenz:google-material-typeface:3.0.1.4.original-kotlin@aar'
implementation 'com.mikepenz:iconics-core:5.3.1'
implementation 'com.mikepenz:materialdrawer-iconics:8.4.2'
implementation 'com.mikepenz:iconics-views:5.3.1'
implementation 'com.mikepenz:google-material-typeface:4.0.0.1-kotlin@aar'
implementation 'com.karumi:dexter:6.2.2'
implementation 'com.karumi:dexter:6.2.3'
implementation 'com.github.ligi:tracedroid:4.1'
@ -202,7 +207,7 @@ dependencies {
// debugImplementation required vs testImplementation: https://issuetracker.google.com/issues/128612536
//noinspection FragmentGradleConfiguration
stagingImplementation("androidx.fragment:fragment-testing:1.3.5") {
stagingImplementation('androidx.fragment:fragment-testing:1.3.6') {
exclude group:'androidx.test', module:'monitor'
}
@ -215,13 +220,13 @@ dependencies {
androidTestImplementation 'androidx.test.uiautomator:uiautomator:2.2.0'
androidTestImplementation 'androidx.test:runner:1.3.0'
androidTestImplementation 'androidx.test:rules:1.3.0'
androidTestImplementation 'androidx.test.ext:junit:1.1.2'
androidTestImplementation 'androidx.test:runner:1.3.0'
androidTestImplementation 'androidx.test:rules:1.3.0'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.3.0'
androidTestImplementation 'androidx.test.espresso:espresso-intents:3.3.0'
androidTestImplementation 'androidx.test:runner:1.4.0'
androidTestImplementation 'androidx.test:rules:1.4.0'
androidTestImplementation 'androidx.test.ext:junit:1.1.3'
androidTestImplementation 'androidx.test:runner:1.4.0'
androidTestImplementation 'androidx.test:rules:1.4.0'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0'
androidTestImplementation 'androidx.test.espresso:espresso-intents:3.4.0'
androidTestImplementation 'com.android.support.test.espresso:espresso-contrib:3.0.2'
androidTestImplementation 'com.squareup.okhttp3:mockwebserver:4.9.0'

View File

@ -515,6 +515,53 @@
license: The Apache Software License, Version 2.0
licenseUrl: http://www.apache.org/licenses/LICENSE-2.0.txt
url: http://developer.android.com/tools/extras/support-library.html
- artifact: androidx.constraintlayout:constraintlayout-core:+
name: constraintlayout-core
copyrightHolder: Google Inc.
license: The Apache Software License, Version 2.0
licenseUrl: http://www.apache.org/licenses/LICENSE-2.0.txt
url: http://tools.android.com
- artifact: androidx.databinding:databinding-ktx:+
name: databinding-ktx
copyrightHolder: Google Inc.
license: The Apache Software License, Version 2.0
licenseUrl: http://www.apache.org/licenses/LICENSE-2.0.txt
- artifact: androidx.lifecycle:lifecycle-extensions:+
name: lifecycle-extensions
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/topic/libraries/architecture/index.html
- artifact: androidx.lifecycle:lifecycle-process:+
name: lifecycle-process
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/topic/libraries/architecture/index.html
- artifact: androidx.lifecycle:lifecycle-service:+
name: lifecycle-service
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/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.
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.5.0
- artifact: androidx.work:work-runtime:+
name: work-runtime
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.5.0
- artifact: androidx.paging:paging-common:+
name: paging-common
copyrightHolder: Google Inc.
@ -587,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
@ -605,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
@ -668,12 +715,6 @@
license: The Apache License, Version 2.0
licenseUrl: http://www.apache.org/licenses/LICENSE-2.0.txt
url: https://kotlinlang.org/
- artifact: androidx.constraintlayout:constraintlayout-solver:+
name: constraintlayout-solver
copyrightHolder: Google Inc.
license: The Apache Software License, Version 2.0
licenseUrl: http://www.apache.org/licenses/LICENSE-2.0.txt
url: http://tools.android.com
- artifact: com.google.guava:listenablefuture:+
name: listenablefuture
copyrightHolder: Google Inc.

View File

@ -14,12 +14,9 @@ import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry.getInstrumentation
import androidx.test.uiautomator.UiDevice
import org.hamcrest.Matchers.allOf
import org.junit.*
import org.pixeldroid.app.testUtility.*
import org.pixeldroid.app.utils.db.AppDatabase
import org.junit.After
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.rules.Timeout
import org.junit.runner.RunWith
import org.pixeldroid.app.testUtility.ToolbarMatchers.withToolbarSubtitle
@ -91,6 +88,7 @@ class DrawerMenuTest {
}
@Test
@Ignore
fun testDrawerLogoutButton() {
// Start the screen of your activity.
onView(withText(R.string.logout)).perform(click())
@ -133,7 +131,7 @@ class DrawerMenuTest {
onView(withText(followingText)).perform(click())
waitForView(R.id.account_entry_avatar)
onView(withText("@User 1")).check(matches(isDisplayed()))
onView(withText("User 2")).check(matches(isDisplayed()))
}
/*@Test

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,9 +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,
@ -229,7 +235,7 @@ class LoginActivity : BaseActivity() {
"client_id" + "=" + client_id + "&" +
"redirect_uri" + "=" + "$oauthScheme://$PACKAGE_ID" + "&" +
"response_type=code" + "&" +
"scope=$SCOPE"
"scope=${SCOPE.replace(" ", "%20")}"
if (!openUrl(url)) return failedRegistration(getString(R.string.browser_launch_failed))
}
@ -311,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,10 +76,12 @@ 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) {
launchActivity(LoginActivity(), firstTime = true)
finish()
launchActivity(LoginActivity(), firstTime = true)
} else {
sendTraceDroidStackTracesIfExist("contact@pixeldroid.org", this)
@ -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, instanceOfNotification)
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()
@ -123,14 +165,14 @@ class MainActivity : BaseActivity() {
DrawerImageLoader.init(object : AbstractDrawerImageLoader() {
override fun set(imageView: ImageView, uri: Uri, placeholder: Drawable, tag: String?) {
Glide.with(imageView.context)
Glide.with(this@MainActivity)
.load(uri)
.placeholder(placeholder)
.into(imageView)
}
override fun cancel(imageView: ImageView) {
Glide.with(imageView.context).clear(imageView)
Glide.with(this@MainActivity).clear(imageView)
}
override fun placeholder(ctx: Context, tag: String?): Drawable {
@ -172,6 +214,10 @@ class MainActivity : BaseActivity() {
}
private fun logOut(){
finish()
removeNotificationChannelsFromAccount(applicationContext, user)
db.runInTransaction {
db.userDao().deleteActiveUsers()
@ -181,7 +227,7 @@ class MainActivity : BaseActivity() {
launchActivity(LoginActivity(), firstTime = true)
} else {
val newActive = remainingUsers.first()
db.userDao().activateUser(newActive.user_id)
db.userDao().activateUser(newActive.user_id, newActive.instance_uri)
apiHolder.setToCurrentUser()
//relaunch the app
launchActivity(MainActivity(), firstTime = true)
@ -224,16 +270,23 @@ class MainActivity : BaseActivity() {
return false
}
db.userDao().deActivateActiveUsers()
db.userDao().activateUser(profile.identifier.toString())
apiHolder.setToCurrentUser()
switchUser(profile.identifier.toString(), profile.tag as String)
val intent = Intent(this, MainActivity::class.java)
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
finish()
startActivity(intent)
return false
}
private fun switchUser(userId: String, instance_uri: String) {
db.userDao().deActivateActiveUsers()
db.userDao().activateUser(userId, instance_uri)
apiHolder.setToCurrentUser()
}
private inline fun primaryDrawerItem(block: PrimaryDrawerItem.() -> Unit): PrimaryDrawerItem {
return PrimaryDrawerItem()
.apply {
@ -259,7 +312,8 @@ 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
tag = user.instance_uri
}
}.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

@ -18,6 +18,7 @@ import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.ImageButton
import androidx.activity.result.contract.ActivityResultContracts
import androidx.camera.core.*
import androidx.camera.core.ImageCapture.Metadata
import androidx.camera.lifecycle.ProcessCameraProvider
@ -47,9 +48,15 @@ import kotlin.properties.Delegates
// request. Where an app has multiple context for requesting permission,
// this can help differentiate the different contexts.
private const val REQUEST_CODE_PERMISSIONS = 10
private const val ANIMATION_FAST_MILLIS = 50L
private const val ANIMATION_SLOW_MILLIS = 100L
private val REQUIRED_PERMISSIONS = arrayOf(
Manifest.permission.CAMERA,
Manifest.permission.READ_EXTERNAL_STORAGE
)
/**
* Camera fragment
*/
@ -57,12 +64,8 @@ class CameraFragment : Fragment() {
private lateinit var container: ConstraintLayout
private lateinit var viewFinder: PreviewView
private val REQUIRED_PERMISSIONS = arrayOf(
Manifest.permission.CAMERA,
Manifest.permission.READ_EXTERNAL_STORAGE
)
private val PICK_IMAGE_REQUEST = 1
private val CAPTURE_IMAGE_REQUEST = 2
private val cameraLifecycleOwner = CameraLifecycleOwner()
private var displayId: Int = -1
private var lensFacing: Int = CameraSelector.LENS_FACING_BACK
@ -86,12 +89,10 @@ class CameraFragment : Fragment() {
REQUEST_CODE_PERMISSIONS
)
} else {
//Bind the viewfinder here, since when leaving the fragment it gets unbound
bindCameraUseCases()
// Build UI controls
updateCameraUi()
}
cameraLifecycleOwner.resume()
}
/**
* Check if all permission specified in the manifest have been granted
@ -145,6 +146,8 @@ class CameraFragment : Fragment() {
// Initialize our background executor
cameraExecutor = Executors.newSingleThreadExecutor()
bindCameraUseCases()
// Every time the orientation of device changes, update rotation for use cases
// Wait for the views to be properly laid out
@ -169,14 +172,12 @@ class CameraFragment : Fragment() {
}
/** Declare and bind preview, capture and analysis use cases */
private fun bindCameraUseCases(forceRebind: Boolean = false) {
private fun bindCameraUseCases() {
// Get screen metrics used to setup camera for full screen resolution
val metrics = DisplayMetrics().also { viewFinder.display?.getRealMetrics(it) }
Log.d(TAG, "Screen metrics: ${metrics.widthPixels} x ${metrics.heightPixels}")
val screenAspectRatio = aspectRatio(metrics.widthPixels, metrics.heightPixels)
Log.d(TAG, "Preview aspect ratio: $screenAspectRatio")
val rotation = viewFinder.display?.rotation ?: 0
@ -188,48 +189,65 @@ class CameraFragment : Fragment() {
// CameraProvider
val cameraProvider: ProcessCameraProvider = cameraProviderFuture.get()
if (forceRebind || camera == null || preview == null || imageCapture == null || !cameraProvider.isBound(preview!!) || !cameraProvider.isBound(imageCapture!!)) {
// Preview
preview = Preview.Builder()
// We request aspect ratio but no resolution
.setTargetAspectRatio(screenAspectRatio)
// Set initial target rotation
.setTargetRotation(rotation)
.build()
// Preview
preview = Preview.Builder()
// We request aspect ratio but no resolution
.setTargetAspectRatio(screenAspectRatio)
// Set initial target rotation
.setTargetRotation(rotation)
.build()
// ImageCapture
imageCapture = ImageCapture.Builder()
.setCaptureMode(ImageCapture.CAPTURE_MODE_MAXIMIZE_QUALITY)
// We request aspect ratio but no resolution to match preview config, but letting
// CameraX optimize for whatever specific resolution best fits our use cases
.setTargetAspectRatio(screenAspectRatio)
// Set initial target rotation, we will have to call this again if rotation changes
// during the lifecycle of this use case
.setTargetRotation(rotation)
.build()
// ImageCapture
imageCapture = ImageCapture.Builder()
.setCaptureMode(ImageCapture.CAPTURE_MODE_MAXIMIZE_QUALITY)
// We request aspect ratio but no resolution to match preview config, but letting
// CameraX optimize for whatever specific resolution best fits our use cases
.setTargetAspectRatio(screenAspectRatio)
// Set initial target rotation, we will have to call this again if rotation changes
// during the lifecycle of this use case
.setTargetRotation(rotation)
.build()
// Must unbind the use-cases before rebinding them
cameraProvider.unbindAll()
// Must unbind the use-cases before rebinding them
cameraProvider.unbindAll()
try {
// A variable number of use-cases can be passed here -
// camera provides access to CameraControl & CameraInfo
camera = cameraProvider.bindToLifecycle(
cameraLifecycleOwner, cameraSelector, preview, imageCapture
)
try {
// A variable number of use-cases can be passed here -
// camera provides access to CameraControl & CameraInfo
camera = cameraProvider.bindToLifecycle(
this, cameraSelector, preview, imageCapture
)
// Attach the viewfinder's surface provider to preview use case
preview?.setSurfaceProvider(viewFinder.surfaceProvider)
} catch (exc: Exception) {
Log.e(TAG, "Use case binding failed", exc)
}
// Attach the viewfinder's surface provider to preview use case
preview?.setSurfaceProvider(viewFinder.surfaceProvider)
} catch (exc: Exception) {
Log.e(TAG, "Use case binding failed", exc)
}
}, ContextCompat.getMainExecutor(requireContext()))
}
override fun onPause() {
super.onPause()
cameraLifecycleOwner.pause()
}
override fun onDestroy() {
super.onDestroy()
cameraLifecycleOwner.destroy()
}
override fun onStop() {
super.onStop()
cameraLifecycleOwner.stop()
}
override fun onStart() {
super.onStart()
cameraLifecycleOwner.start()
}
/**
* setTargetAspectRatio requires enum value of
* [androidx.camera.core.AspectRatio]. Currently it has values of 4:3 & 16:9.
@ -292,6 +310,25 @@ class CameraFragment : Fragment() {
setupUploadImage(controls)
}
private val uploadImageResultContract = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
val data: Intent? = result.data
if (result.resultCode == Activity.RESULT_OK && data != null) {
val images: ArrayList<String> = ArrayList()
val clipData = data.clipData
if (clipData != null) {
val count = clipData.itemCount
for (i in 0 until count) {
val imageUri: String = clipData.getItemAt(i).uri.toString()
images.add(imageUri)
}
startAlbumCreation(images)
} else if (data.data != null) {
images.add(data.data!!.toString())
startAlbumCreation(images)
}
}
}
private fun setupUploadImage(controls: View) {
// Listener for button used to view the most recent photo
controls.findViewById<ImageButton>(R.id.photo_view_button)?.setOnClickListener {
@ -301,8 +338,8 @@ class CameraFragment : Fragment() {
addCategory(Intent.CATEGORY_OPENABLE)
putExtra(Intent.EXTRA_LOCAL_ONLY, true)
putExtra(Intent.EXTRA_ALLOW_MULTIPLE, true)
startActivityForResult(
Intent.createChooser(this, "Select a Picture"), PICK_IMAGE_REQUEST
uploadImageResultContract.launch(
Intent.createChooser(this, null)
)
}
}
@ -324,7 +361,7 @@ class CameraFragment : Fragment() {
REQUEST_CODE_PERMISSIONS
)
} else {
bindCameraUseCases(forceRebind = true)
bindCameraUseCases()
}
}
}
@ -379,25 +416,6 @@ class CameraFragment : Fragment() {
}
}
}
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
super.onActivityResult(requestCode, resultCode, data)
if (resultCode == Activity.RESULT_OK && data != null
&& (requestCode == PICK_IMAGE_REQUEST || requestCode == CAPTURE_IMAGE_REQUEST)) {
val images: ArrayList<String> = ArrayList()
if (data.clipData != null) {
val count = data.clipData!!.itemCount
for (i in 0 until count) {
val imageUri: String = data.clipData!!.getItemAt(i).uri.toString()
images.add(imageUri)
}
startAlbumCreation(images)
} else if (data.data != null) {
images.add(data.data!!.toString())
startAlbumCreation(images)
}
}
}
private fun startAlbumCreation(uris: ArrayList<String>) {

View File

@ -0,0 +1,38 @@
package org.pixeldroid.app.postCreation.camera
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.LifecycleRegistry
class CameraLifecycleOwner : LifecycleOwner {
private val lifecycleRegistry: LifecycleRegistry = LifecycleRegistry(this)
init {
lifecycleRegistry.currentState = Lifecycle.State.INITIALIZED
lifecycleRegistry.currentState = Lifecycle.State.CREATED
}
fun resume() {
lifecycleRegistry.currentState = Lifecycle.State.RESUMED
}
fun pause() {
lifecycleRegistry.currentState = Lifecycle.State.STARTED
lifecycleRegistry.currentState = Lifecycle.State.CREATED
}
fun destroy() {
lifecycleRegistry.currentState = Lifecycle.State.DESTROYED
}
fun start() {
lifecycleRegistry.currentState = Lifecycle.State.STARTED
}
fun stop() {
lifecycleRegistry.currentState = Lifecycle.State.CREATED
}
override fun getLifecycle(): Lifecycle {
return lifecycleRegistry
}
}

View File

@ -1,6 +1,8 @@
package org.pixeldroid.app.postCreation.photoEdit
import android.graphics.Bitmap
import android.graphics.ImageDecoder
import android.os.Build
import android.os.Bundle
import android.provider.MediaStore
import android.util.TypedValue
@ -16,6 +18,7 @@ import com.zomato.photofilters.FilterPack
import com.zomato.photofilters.imageprocessors.Filter
import com.zomato.photofilters.utils.ThumbnailItem
import com.zomato.photofilters.utils.ThumbnailsManager
import org.pixeldroid.app.utils.bitmapFromUri
class FilterListFragment : Fragment() {
@ -54,17 +57,7 @@ class FilterListFragment : Fragment() {
private fun displayImage(bitmap: Bitmap?) {
val r = Runnable {
val tbImage: Bitmap = (if (bitmap == null) {
// TODO: Shouldn't use deprecated API on newer versions of Android,
// but the proper way to do it seems to crash for OpenGL reasons
//if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
// ImageDecoder.decodeBitmap(
// ImageDecoder.createSource(requireActivity().contentResolver, PhotoEditActivity.imageUri!!))
//} else {
MediaStore.Images.Media.getBitmap(
requireActivity().contentResolver,
PhotoEditActivity.imageUri
)
//}
bitmapFromUri(requireActivity().contentResolver, PhotoEditActivity.imageUri)
} else {
Bitmap.createScaledBitmap(bitmap, 100, 100, false)
})

View File

@ -9,7 +9,6 @@ import android.graphics.Point
import android.graphics.drawable.BitmapDrawable
import android.net.Uri
import android.os.Bundle
import android.provider.MediaStore
import android.view.Menu
import android.view.MenuItem
import android.view.View.GONE
@ -28,6 +27,7 @@ import com.zomato.photofilters.imageprocessors.Filter
import com.zomato.photofilters.imageprocessors.subfilters.BrightnessSubFilter
import com.zomato.photofilters.imageprocessors.subfilters.ContrastSubFilter
import com.zomato.photofilters.imageprocessors.subfilters.SaturationSubfilter
import org.pixeldroid.app.utils.bitmapFromUri
import java.io.File
import java.io.IOException
import java.io.OutputStream
@ -114,9 +114,9 @@ class PhotoEditActivity : BaseActivity() {
binding.tabs.setupWithViewPager(binding.viewPager)
}
private fun loadImage() {
originalImage = MediaStore.Images.Media.getBitmap(contentResolver, imageUri)
originalImage = bitmapFromUri(contentResolver, imageUri)
compressedImage = resizeImage(originalImage!!)
compressedOriginalImage = compressedImage!!.copy(BITMAP_CONFIG, true)
filteredImage = compressedImage!!.copy(BITMAP_CONFIG, true)

View File

@ -36,7 +36,7 @@ class ThumbnailAdapter (private val context: Context,
holder.thumbnail.setImageBitmap(tbItem.image)
holder.thumbnail.setOnClickListener {
listener.onFilterSelected(tbItem.filter)
selectedIndex = position
selectedIndex = holder.bindingAdapterPosition
notifyDataSetChanged()
}

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

@ -38,8 +38,8 @@ class PostActivity : BaseActivity() {
supportActionBar?.setDisplayHomeAsUpEnabled(true)
status = intent.getSerializableExtra(POST_TAG) as Status
val viewComments: Boolean = (intent.getSerializableExtra(VIEW_COMMENTS_TAG) ?: false) as Boolean
val postComment: Boolean = (intent.getSerializableExtra(POST_COMMENT_TAG) ?: false) as Boolean
val viewComments: Boolean = intent.getBooleanExtra(VIEW_COMMENTS_TAG, false)
val postComment: Boolean = intent.getBooleanExtra(POST_COMMENT_TAG, false)
val user = db.userDao().getActiveUser()

View File

@ -87,7 +87,8 @@ class NotificationsFragment : CachedFeedFragment<Notification>() {
val intent: Intent =
when (type) {
Notification.NotificationType.mention, Notification.NotificationType.favourite,
Notification.NotificationType.poll, Notification.NotificationType.reblog -> {
Notification.NotificationType.poll, Notification.NotificationType.reblog,
Notification.NotificationType.comment -> {
openPostFromNotification()
}
Notification.NotificationType.follow -> {
@ -113,39 +114,38 @@ class NotificationsFragment : CachedFeedFragment<Notification>() {
) {
val context = textView.context
val (format: String, drawable: Drawable?) = when (type) {
Notification.NotificationType.follow -> {
Notification.NotificationType.follow ->
getStringAndDrawable(
context,
R.string.followed_notification,
R.drawable.ic_follow
)
}
Notification.NotificationType.mention -> {
Notification.NotificationType.mention ->
getStringAndDrawable(
context,
R.string.mention_notification,
R.drawable.mention_at_24dp
)
}
Notification.NotificationType.reblog -> {
Notification.NotificationType.comment ->
getStringAndDrawable(
context,
R.string.comment_notification,
R.drawable.ic_comment_empty
)
Notification.NotificationType.reblog ->
getStringAndDrawable(
context,
R.string.shared_notification,
R.drawable.ic_reblog_blue
)
}
Notification.NotificationType.favourite -> {
Notification.NotificationType.favourite ->
getStringAndDrawable(
context,
R.string.liked_notification,
R.drawable.ic_like_full
)
}
Notification.NotificationType.poll -> {
Notification.NotificationType.poll ->
getStringAndDrawable(context, R.string.poll_notification, R.drawable.poll)
}
}
textView.text = format.format(username)
textView.setCompoundDrawablesWithIntrinsicBounds(
@ -169,10 +169,10 @@ class NotificationsFragment : CachedFeedFragment<Notification>() {
this.notification = notification
Glide.with(itemView).load(notification?.account?.avatar_static).circleCrop()
Glide.with(itemView).load(notification?.account?.anyAvatar()).circleCrop()
.into(avatar)
val previewUrl = notification?.status?.media_attachments?.getOrNull(0)?.preview_url
val previewUrl = notification?.status?.getPostPreviewURL()
if (!previewUrl.isNullOrBlank()) {
Glide.with(itemView).load(previewUrl)
.placeholder(R.drawable.ic_picture_fallback).into(photoThumbnail)
@ -249,7 +249,7 @@ class NotificationsFragment : CachedFeedFragment<Notification>() {
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
val uiModel = getItem(position)
uiModel.let {
uiModel?.let {
(holder as NotificationViewHolder).bind(
it,
apiHolder,

View File

@ -73,7 +73,7 @@ class NotificationsRemoteMediator @Inject constructor(
db.withTransaction {
// clear table in the database
if (loadType == LoadType.REFRESH) {
db.notificationDao().clearFeedContent()
db.notificationDao().clearFeedContent(user.user_id, user.instance_uri)
}
db.notificationDao().insertAll(apiResponse)
}

View File

@ -59,7 +59,7 @@ class HomeFeedRemoteMediator @Inject constructor(
db.withTransaction {
// clear table in the database
if (loadType == LoadType.REFRESH) {
db.homePostDao().clearFeedContent()
db.homePostDao().clearFeedContent(user.user_id, user.instance_uri)
}
db.homePostDao().insertAll(dbObjects)
}

View File

@ -74,7 +74,7 @@ class PublicFeedRemoteMediator @Inject constructor(
db.withTransaction {
// clear table in the database
if (loadType == LoadType.REFRESH) {
db.publicPostDao().clearFeedContent()
db.publicPostDao().clearFeedContent(user.user_id, user.instance_uri)
}
db.publicPostDao().insertAll(dbObjects)
}

View File

@ -92,7 +92,7 @@ class AccountViewHolder(binding: AccountListEntryBinding) : RecyclerView.ViewHol
this.account = account
Glide.with(itemView)
.load(account?.avatar_static ?: account?.avatar)
.load(account?.anyAvatar())
.circleCrop().placeholder(R.drawable.ic_default_user)
.into(avatar)

View File

@ -3,8 +3,6 @@ package org.pixeldroid.app.posts.feeds.uncachedFeeds.hashtags
import androidx.paging.PagingSource
import androidx.paging.PagingState
import org.pixeldroid.app.utils.api.PixelfedAPI
import org.pixeldroid.app.utils.api.objects.FeedContent
import org.pixeldroid.app.utils.api.objects.Results
import org.pixeldroid.app.utils.api.objects.Status
import retrofit2.HttpException
import java.io.IOException

View File

@ -33,7 +33,6 @@ import org.pixeldroid.app.utils.BaseActivity
import org.pixeldroid.app.utils.ImageConverter
import org.pixeldroid.app.utils.api.PixelfedAPI
import org.pixeldroid.app.utils.api.objects.Account
import org.pixeldroid.app.utils.api.objects.FeedContent
import org.pixeldroid.app.utils.api.objects.Status
import org.pixeldroid.app.utils.db.entities.UserDatabaseEntity
import org.pixeldroid.app.utils.openUrl
@ -146,7 +145,7 @@ class ProfileActivity : BaseActivity() {
val profilePicture = binding.profilePictureImageView
ImageConverter.setRoundImageFromURL(
View(applicationContext),
account.avatar,
account.anyAvatar(),
profilePicture
)

View File

@ -121,7 +121,7 @@ class SearchDiscoverFragment : BaseFragment() {
} else {
holder.albumIcon.visibility = View.GONE
}
ImageConverter.setSquareImageFromURL(holder.postView, post?.media_attachments?.firstOrNull()?.preview_url, holder.postPreview, post?.media_attachments?.firstOrNull()?.blurhash)
ImageConverter.setSquareImageFromURL(holder.postView, post?.getPostPreviewURL(), holder.postPreview, post?.media_attachments?.firstOrNull()?.blurhash)
holder.postPreview.setOnClickListener {
val intent = Intent(holder.postView.context, PostActivity::class.java)
intent.putExtra(Status.POST_TAG, post)

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,24 +1,26 @@
package org.pixeldroid.app.utils
import android.content.ActivityNotFoundException
import android.content.Context
import android.content.Intent
import android.content.SharedPreferences
import android.content.*
import android.content.res.Resources
import android.graphics.Bitmap
import android.graphics.ImageDecoder
import android.graphics.Matrix
import android.net.ConnectivityManager
import android.net.Uri
import android.os.Build
import android.provider.MediaStore
import android.util.DisplayMetrics
import android.view.WindowManager
import androidx.appcompat.app.AppCompatDelegate
import androidx.browser.customtabs.CustomTabsIntent
import androidx.exifinterface.media.ExifInterface
import androidx.fragment.app.Fragment
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import org.pixeldroid.app.R
import okhttp3.HttpUrl
import org.pixeldroid.app.R
import kotlin.properties.ReadWriteProperty
import kotlin.reflect.KProperty
@ -63,6 +65,47 @@ fun normalizeDomain(domain: String): String {
.trim(Char::isWhitespace)
}
fun bitmapFromUri(contentResolver: ContentResolver, uri: Uri?): Bitmap =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
ImageDecoder
.decodeBitmap(
ImageDecoder.createSource(contentResolver, uri!!)
)
{ decoder, _, _ -> decoder.isMutableRequired = true }
} else {
val bitmap = MediaStore.Images.Media.getBitmap(contentResolver, uri)
modifyOrientation(bitmap!!, contentResolver, uri!!)
}
fun modifyOrientation(
bitmap: Bitmap,
contentResolver: ContentResolver,
uri: Uri
): Bitmap {
val inputStream = contentResolver.openInputStream(uri)!!
val ei = ExifInterface(inputStream)
return when (ei.getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_UNDEFINED)) {
ExifInterface.ORIENTATION_ROTATE_90 -> bitmap.rotate(90f)
ExifInterface.ORIENTATION_ROTATE_180 -> bitmap.rotate(180f)
ExifInterface.ORIENTATION_ROTATE_270 -> bitmap.rotate(270f)
ExifInterface.ORIENTATION_FLIP_HORIZONTAL -> bitmap.flip(horizontal = true, vertical = false)
ExifInterface.ORIENTATION_FLIP_VERTICAL -> bitmap.flip(horizontal = false, vertical = true)
else -> bitmap
}
}
fun Bitmap.rotate(degrees: Float): Bitmap {
val matrix = Matrix()
matrix.postRotate(degrees)
return Bitmap.createBitmap(this, 0, 0, width, height, matrix, true)
}
fun Bitmap.flip(horizontal: Boolean, vertical: Boolean): Bitmap {
val matrix = Matrix()
matrix.preScale(if (horizontal) -1f else 1f, if (vertical) -1f else 1f)
return Bitmap.createBitmap(this, 0, 0, width, height, matrix, true)
}
fun BaseActivity.openUrl(url: String): Boolean{
val intent = CustomTabsIntent.Builder().build()

View File

@ -1,14 +1,23 @@
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
import org.pixeldroid.app.utils.db.entities.UserDatabaseEntity
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
@ -24,10 +33,48 @@ 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(gSonInstance))
.addCallAdapterFactory(RxJava3CallAdapterFactory.create())
fun apiForUser(
user: UserDatabaseEntity,
db: AppDatabase,
pixelfedAPIHolder: PixelfedAPIHolder
): PixelfedAPI =
intermediate
.baseUrl(user.instance_uri)
.client(
OkHttpClient().newBuilder().authenticator(TokenAuthenticator(user, db, pixelfedAPIHolder))
.addInterceptor {
it.request().newBuilder().run {
header("Accept", "application/json")
header("Authorization", "Bearer ${user.accessToken}")
it.proceed(build())
}
}.build()
)
.build().create(PixelfedAPI::class.java)
}

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.
@ -24,15 +25,15 @@ data class Account(
//Display attributes
val display_name: String? = "",
val note: String? = "", //HTML
val avatar: String? = "", //URL
val avatar_static: String? = "", //URL
private val avatar: String? = "", //URL
private val avatar_static: String? = "", //URL
val header: String? = "", //URL
val header_static: String? = "", //URL
val locked: Boolean? = false,
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,
@ -79,6 +80,8 @@ data class Account(
else -> username.orEmpty()
}
fun anyAvatar(): String? = avatar_static ?: avatar
/**
* @brief Open profile activity with given account
*/

View File

@ -38,4 +38,8 @@ data class Attachment(
val aspect: Double?
) : Serializable
}
val previewNoPlaceholder: String?
get() = if (preview_url?.contains(Regex("public/no-preview\\.(png|jpg|webp)")) == true) url else preview_url
}

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,
@ -38,6 +38,6 @@ data class Notification(
override var instance_uri: String,
): FeedContent, FeedContentDatabase {
enum class NotificationType: Serializable {
follow, mention, reblog, favourite, poll
follow, mention, reblog, favourite, poll, comment
}
}

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,
@ -64,8 +64,8 @@ open class Status(
}
fun getPostUrl() : String? = media_attachments?.firstOrNull()?.url
fun getProfilePicUrl() : String? = account?.avatar
fun getPostPreviewURL() : String? = media_attachments?.firstOrNull()?.preview_url
fun getProfilePicUrl() : String? = account?.anyAvatar()
fun getPostPreviewURL() : String? = media_attachments?.firstOrNull()?.previewNoPlaceholder
fun getNLikes(context: Context) : CharSequence {

View File

@ -3,6 +3,8 @@ package org.pixeldroid.app.utils.db
import androidx.room.Database
import androidx.room.RoomDatabase
import androidx.room.TypeConverters
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
import org.pixeldroid.app.utils.db.dao.*
import org.pixeldroid.app.utils.db.dao.feedContent.NotificationDao
import org.pixeldroid.app.utils.db.dao.feedContent.posts.HomePostDao
@ -20,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() {
@ -29,4 +31,12 @@ abstract class AppDatabase : RoomDatabase() {
abstract fun homePostDao(): HomePostDao
abstract fun publicPostDao(): PublicPostDao
abstract fun notificationDao(): NotificationDao
}
val MIGRATION_3_4 = object : Migration(3, 4) {
override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL("DELETE FROM homePosts")
database.execSQL("DELETE FROM publicPosts")
database.execSQL("DELETE FROM notifications")
}
}

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

@ -14,13 +14,13 @@ import java.lang.IllegalArgumentException
fun addUser(db: AppDatabase, account: Account, instance_uri: String, activeUser: Boolean = true,
accessToken: String, refreshToken: String?, clientId: String, clientSecret: String) {
db.userDao().insertUser(
db.userDao().insertOrUpdate(
UserDatabaseEntity(
user_id = account.id!!,
instance_uri = normalizeDomain(instance_uri),
username = account.username!!,
display_name = account.getDisplayName(),
avatar_static = account.avatar_static.orEmpty(),
avatar_static = account.anyAvatar().orEmpty(),
isActive = activeUser,
accessToken = accessToken,
refreshToken = refreshToken,

View File

@ -5,8 +5,21 @@ import org.pixeldroid.app.utils.db.entities.UserDatabaseEntity
@Dao
interface UserDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
fun insertUser(user: UserDatabaseEntity)
/**
* Insert a user, if it already exists return -1
*/
@Insert(onConflict = OnConflictStrategy.IGNORE)
fun insertUser(user: UserDatabaseEntity): Long
@Transaction
fun insertOrUpdate(user: UserDatabaseEntity) {
if (insertUser(user) == -1L) {
updateUser(user)
}
}
@Update
fun updateUser(user: UserDatabaseEntity)
@Query("UPDATE users SET accessToken = :accessToken, refreshToken = :refreshToken WHERE user_id = :id and instance_uri = :instance_uri")
fun updateAccessToken(accessToken: String, refreshToken: String, id: String, instance_uri: String)
@ -20,12 +33,12 @@ interface UserDao {
@Query("UPDATE users SET isActive=0")
fun deActivateActiveUsers()
@Query("UPDATE users SET isActive=1 WHERE user_id=:id")
fun activateUser(id: String)
@Query("UPDATE users SET isActive=1 WHERE user_id=:id AND instance_uri=:instance_uri")
fun activateUser(id: String, instance_uri: String)
@Query("DELETE FROM users WHERE isActive=1")
fun deleteActiveUsers()
@Query("SELECT * FROM users WHERE user_id=:id LIMIT 1")
fun getUserWithId(id: String): UserDatabaseEntity
@Query("SELECT * FROM users WHERE user_id=:id AND instance_uri=:instance_uri LIMIT 1")
fun getUserWithId(id: String, instance_uri: String): UserDatabaseEntity
}

View File

@ -9,7 +9,7 @@ interface FeedContentDao<T: FeedContentDatabase>{
fun feedContent(userId: String, instanceUri: String): PagingSource<Int, T>
suspend fun clearFeedContent()
suspend fun clearFeedContent(userId: String, instanceUri: String)
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertAll(feedContent: List<T>)

View File

@ -8,13 +8,17 @@ import org.pixeldroid.app.utils.api.objects.Notification
@Dao
interface NotificationDao: FeedContentDao<Notification> {
@Query("DELETE FROM notifications")
override suspend fun clearFeedContent()
@Query("DELETE FROM notifications WHERE user_id=:userId AND instance_uri=:instanceUri")
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,11 +9,11 @@ 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")
override suspend fun clearFeedContent()
@Query("DELETE FROM homePosts WHERE user_id=:userId AND instance_uri=:instanceUri")
override suspend fun clearFeedContent(userId: String, instanceUri: String)
@Query("DELETE FROM homePosts WHERE user_id=:userId AND instance_uri=:instanceUri AND id=:id")
override suspend fun delete(id: String, userId: String, instanceUri: String)

View File

@ -9,11 +9,11 @@ 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")
override suspend fun clearFeedContent()
@Query("DELETE FROM publicPosts WHERE user_id=:userId AND instance_uri=:instanceUri")
override suspend fun clearFeedContent(userId: String, instanceUri: String)
@Query("DELETE FROM publicPosts WHERE user_id=:userId AND instance_uri=:instanceUri AND id=:id")
override suspend fun delete(id: String, userId: String, instanceUri: String)

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

@ -8,9 +8,7 @@ import dagger.Module
import dagger.Provides
import kotlinx.coroutines.runBlocking
import okhttp3.*
import retrofit2.Retrofit
import retrofit2.adapter.rxjava2.RxJava2CallAdapterFactory
import retrofit2.converter.gson.GsonConverterFactory
import org.pixeldroid.app.utils.api.PixelfedAPI.Companion.apiForUser
import javax.inject.Singleton
@Module
@ -78,9 +76,6 @@ class TokenAuthenticator(val user: UserDatabaseEntity, val db: AppDatabase, val
}
class PixelfedAPIHolder(private val db: AppDatabase){
private val intermediate: Retrofit.Builder = Retrofit.Builder()
.addConverterFactory(GsonConverterFactory.create())
.addCallAdapterFactory(RxJava2CallAdapterFactory.create())
var api: PixelfedAPI? =
db.userDao().getActiveUser()?.let {
@ -90,21 +85,8 @@ class PixelfedAPIHolder(private val db: AppDatabase){
fun setToCurrentUser(
user: UserDatabaseEntity = db.userDao().getActiveUser()!!
): PixelfedAPI {
val newAPI = intermediate
.baseUrl(user.instance_uri)
.client(
OkHttpClient().newBuilder().authenticator(TokenAuthenticator(user, db, this))
.addInterceptor {
it.request().newBuilder().run {
header("Accept", "application/json")
header("Authorization", "Bearer ${user.accessToken}")
it.proceed(build())
}
}.build()
)
.build().create(PixelfedAPI::class.java)
val newAPI = apiForUser(user, db, this)
api = newAPI
return newAPI
}
}

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

@ -5,6 +5,7 @@ import androidx.room.Room
import org.pixeldroid.app.utils.db.AppDatabase
import dagger.Module
import dagger.Provides
import org.pixeldroid.app.utils.db.MIGRATION_3_4
import javax.inject.Singleton
@Module
@ -16,6 +17,6 @@ class DatabaseModule(private val context: Context) {
return Room.databaseBuilder(
context,
AppDatabase::class.java, "pixeldroid"
).allowMainThreadQueries().build()
).addMigrations(MIGRATION_3_4).allowMainThreadQueries().build()
}
}

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

@ -17,6 +17,7 @@
<item>العربية</item>
<item>বাংলা (বাংলাদেশ)</item>
<item>Català</item>
<item>Čeština</item>
<item>Deutsch</item>
<item>Español</item>
<item>Euskara</item>
@ -43,6 +44,7 @@
<item>ar</item>
<item>bn-bd</item>
<item>ca</item>
<item>cs</item>
<item>de</item>
<item>es</item>
<item>eu</item>

View File

@ -47,9 +47,25 @@
<!-- Notifications: like (favourite) notification -->
<string name="liked_notification">%1$s liked your post</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'.
@ -229,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!!.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==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,13 +1,13 @@
// Top-level build file where you can add configuration options common to all sub-projects/modules.
buildscript {
ext.kotlin_version = '1.5.20'
ext.kotlin_version = '1.5.30'
repositories {
google()
mavenCentral()
}
dependencies {
classpath 'com.android.tools.build:gradle:4.2.1'
classpath 'com.android.tools.build:gradle:7.0.2'
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
// NOTE: Do not place your application dependencies here; they belong

View File

@ -0,0 +1,9 @@
- Notification support! Still a bit rudimentary, some polish is incoming :)
- Fix an issue causing the EXIF rotation to be ignored, making photos appear turned the wrong way
- Fix #300
- Fix webview browsers showing an error on login because the URL contained spaces that weren't URI-encoded
- Fix issue causing caches to be flushed and making the feeds flash empty on every app launch, and causing performance problems
- Add Czech, update other translations
- Fix profile pictures
- Fix camera not working after switching tabs
- Update dependencies

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-all.zip
distributionSha256Sum=81003f83b0056d20eedf48cddd4f52a9813163d4ba185bcf8abd34b8eeea4cbd
zipStoreBase=GRADLE_USER_HOME
distributionSha256Sum=f581709a9c35e9cb92e16f585d2c4bc99b2b1a5f85d2badbd3dc6bff59e1e6dd