Cache feeds with database using the new paging3 API

This commit is contained in:
Matthieu 2020-11-27 17:02:52 +01:00
parent 6103b05fad
commit 624cff117a
81 changed files with 3333 additions and 2622 deletions

View File

@ -11,13 +11,14 @@ apply plugin: 'jacoco'
android {
compileSdkVersion 30
buildToolsVersion '30.0.1'
buildToolsVersion '30.0.2'
compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = "1.8"
freeCompilerArgs += ["-Xopt-in=kotlin.RequiresOptIn"]
}
defaultConfig {
applicationId "com.h.pixeldroid"
@ -53,6 +54,9 @@ android {
animationsDisabled true
}
buildFeatures {
viewBinding = true
}
apply plugin: 'kotlin-kapt'
}
@ -75,7 +79,7 @@ dependencies {
implementation "androidx.swiperefreshlayout:swiperefreshlayout:1.1.0"
implementation 'androidx.navigation:navigation-fragment-ktx:2.3.1'
implementation 'androidx.navigation:navigation-ui-ktx:2.3.1'
implementation 'androidx.paging:paging-runtime-ktx:2.1.2'
implementation 'androidx.paging:paging-runtime-ktx:3.0.0-alpha09'
implementation 'androidx.lifecycle:lifecycle-livedata-ktx:2.2.0'
implementation 'androidx.lifecycle:lifecycle-viewmodel-savedstate:2.2.0'
implementation "androidx.lifecycle:lifecycle-runtime-ktx:2.2.0"
@ -83,16 +87,16 @@ dependencies {
implementation 'androidx.gridlayout:gridlayout:1.0.0'
// Use the most recent version of CameraX
def camerax_version = '1.0.0-beta11'
def camerax_version = '1.0.0-beta12'
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-alpha18'
implementation 'androidx.camera:camera-view:1.0.0-alpha19'
def room_version = "2.2.5"
def room_version = "2.3.0-alpha03"
implementation "androidx.room:room-runtime:$room_version"
kapt "androidx.room:room-compiler:$room_version"
implementation "androidx.room:room-ktx:$room_version"
@ -148,7 +152,7 @@ dependencies {
implementation 'com.mikepenz:google-material-typeface:3.0.1.4.original-kotlin@aar'
implementation 'com.karumi:dexter:6.2.1'
implementation 'com.karumi:dexter:6.2.2'
implementation 'com.github.ligi.tracedroid:lib:3.0'
implementation 'com.github.ligi.tracedroid:supportemail:3.0'

View File

@ -630,8 +630,13 @@
license: The Apache Software License, Version 2.0
licenseUrl: https://www.apache.org/licenses/LICENSE-2.0.txt
url: https://github.com/Kotlin/kotlinx.coroutines
- artifact: org.jetbrains.kotlinx:kotlinx-coroutines-core:+
name: kotlinx-coroutines-core
- artifact: androidx.databinding:viewbinding:+
name: viewbinding
copyrightHolder: Google Inc.
license: The Apache Software License, Version 2.0
licenseUrl: http://www.apache.org/licenses/LICENSE-2.0.txt
- artifact: org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm:+
name: kotlinx-coroutines-core-jvm
copyrightHolder: JetBrains s.r.o. and contributors
license: The Apache Software License, Version 2.0
licenseUrl: https://www.apache.org/licenses/LICENSE-2.0.txt

View File

@ -9,8 +9,8 @@ import androidx.test.core.app.ApplicationProvider
import androidx.test.espresso.intent.Intents
import androidx.test.espresso.intent.matcher.IntentMatchers
import androidx.test.rule.GrantPermissionRule
import com.h.pixeldroid.db.InstanceDatabaseEntity
import com.h.pixeldroid.db.UserDatabaseEntity
import com.h.pixeldroid.db.entities.InstanceDatabaseEntity
import com.h.pixeldroid.db.entities.UserDatabaseEntity
import com.h.pixeldroid.fragments.CameraFragment
import com.h.pixeldroid.testUtility.clearData
import com.h.pixeldroid.testUtility.initDB

View File

@ -13,8 +13,8 @@ import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry.getInstrumentation
import androidx.test.uiautomator.UiDevice
import com.h.pixeldroid.db.AppDatabase
import com.h.pixeldroid.db.InstanceDatabaseEntity
import com.h.pixeldroid.db.UserDatabaseEntity
import com.h.pixeldroid.db.entities.InstanceDatabaseEntity
import com.h.pixeldroid.db.entities.UserDatabaseEntity
import com.h.pixeldroid.testUtility.MockServer
import com.h.pixeldroid.testUtility.clearData
import com.h.pixeldroid.testUtility.initDB

View File

@ -2,12 +2,9 @@ package com.h.pixeldroid
import android.content.Context
import android.widget.TextView
import androidx.test.core.app.ActivityScenario
import androidx.test.core.app.ApplicationProvider
import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.action.ViewActions.click
import androidx.test.espresso.action.ViewActions.swipeUp
import androidx.test.espresso.assertion.ViewAssertions.matches
import androidx.test.espresso.contrib.RecyclerViewActions.actionOnItemAtPosition
import androidx.test.espresso.contrib.RecyclerViewActions.scrollToPosition
@ -15,9 +12,9 @@ import androidx.test.espresso.matcher.ViewMatchers.*
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.google.android.material.tabs.TabLayout
import com.h.pixeldroid.db.AppDatabase
import com.h.pixeldroid.db.InstanceDatabaseEntity
import com.h.pixeldroid.db.UserDatabaseEntity
import com.h.pixeldroid.fragments.feeds.postFeeds.PostViewHolder
import com.h.pixeldroid.db.entities.InstanceDatabaseEntity
import com.h.pixeldroid.db.entities.UserDatabaseEntity
import com.h.pixeldroid.fragments.StatusViewHolder
import com.h.pixeldroid.testUtility.CustomMatchers.Companion.atPosition
import com.h.pixeldroid.testUtility.CustomMatchers.Companion.clickChildViewWithId
import com.h.pixeldroid.testUtility.CustomMatchers.Companion.first
@ -99,10 +96,10 @@ class HomeFeedTest {
@Test
fun clickingLikeButtonWorks() {
onView(withId(R.id.list)).perform(
actionOnItemAtPosition<PostViewHolder>(0, clickChildViewWithId(R.id.liker))
actionOnItemAtPosition<StatusViewHolder>(0, clickChildViewWithId(R.id.liker))
)
onView(withId(R.id.list)).perform(
actionOnItemAtPosition<PostViewHolder>(0, clickChildViewWithId(R.id.liker))
actionOnItemAtPosition<StatusViewHolder>(0, clickChildViewWithId(R.id.liker))
)
onView(first(withId(R.id.nlikes)))
.check(matches(withText(getText(first(withId(R.id.nlikes))))))
@ -111,7 +108,7 @@ class HomeFeedTest {
@Test
fun clickingLikeButtonFails() {
onView(withId(R.id.list)).perform(
actionOnItemAtPosition<PostViewHolder>(2, clickChildViewWithId(R.id.liker))
actionOnItemAtPosition<StatusViewHolder>(2, clickChildViewWithId(R.id.liker))
)
onView((withId(R.id.list))).check(matches(isDisplayed()))
}
@ -119,7 +116,7 @@ class HomeFeedTest {
@Test
fun clickingUsernameOpensProfile() {
onView(withId(R.id.list)).perform(
actionOnItemAtPosition<PostViewHolder>(0, clickChildViewWithId(R.id.username))
actionOnItemAtPosition<StatusViewHolder>(0, clickChildViewWithId(R.id.username))
)
onView(withId(R.id.accountNameTextView)).check(matches(isDisplayed()))
}
@ -127,7 +124,7 @@ class HomeFeedTest {
@Test
fun clickingProfilePicOpensProfile() {
onView(withId(R.id.list)).perform(
actionOnItemAtPosition<PostViewHolder>(0, clickChildViewWithId(R.id.profilePic))
actionOnItemAtPosition<StatusViewHolder>(0, clickChildViewWithId(R.id.profilePic))
)
onView(withId(R.id.accountNameTextView)).check(matches(isDisplayed()))
}
@ -135,10 +132,10 @@ class HomeFeedTest {
@Test
fun clickingReblogButtonWorks() {
onView(withId(R.id.list))
.perform(actionOnItemAtPosition<PostViewHolder>
.perform(actionOnItemAtPosition<StatusViewHolder>
(0, clickChildViewWithId(R.id.reblogger)))
onView(withId(R.id.list))
.perform(actionOnItemAtPosition<PostViewHolder>
.perform(actionOnItemAtPosition<StatusViewHolder>
(0, clickChildViewWithId(R.id.reblogger)))
onView(first(withId(R.id.nshares)))
.check(matches(withText(getText(first(withId(R.id.nshares))))))
@ -147,7 +144,7 @@ class HomeFeedTest {
@Test
fun clickingMentionOpensProfile() {
onView(withId(R.id.list)).perform(
actionOnItemAtPosition<PostViewHolder>(0, clickChildViewWithId(R.id.description))
actionOnItemAtPosition<StatusViewHolder>(0, clickChildViewWithId(R.id.description))
)
onView(first(withId(R.id.username))).check(matches(isDisplayed()))
}
@ -155,7 +152,7 @@ class HomeFeedTest {
@Test
fun clickingHashTagsWorks() {
onView(withId(R.id.list)).perform(
actionOnItemAtPosition<PostViewHolder>(1, clickChildViewWithId(R.id.description))
actionOnItemAtPosition<StatusViewHolder>(1, clickChildViewWithId(R.id.description))
)
onView(withId(R.id.list)).check(matches(isDisplayed()))
}
@ -164,7 +161,7 @@ class HomeFeedTest {
@Test
fun clickingCommentButtonOpensCommentSection() {
onView(withId(R.id.list)).perform(
actionOnItemAtPosition<PostViewHolder>(0, clickChildViewWithId(R.id.commenter))
actionOnItemAtPosition<StatusViewHolder>(0, clickChildViewWithId(R.id.commenter))
)
onView(first(withId(R.id.commentIn)))
.check(matches(hasDescendant(withId(R.id.editComment))))
@ -174,7 +171,7 @@ class HomeFeedTest {
fun clickingViewCommentShowsTheComments() {
//Open the comment section
onView(withId(R.id.list))
.perform(actionOnItemAtPosition<PostViewHolder>
.perform(actionOnItemAtPosition<StatusViewHolder>
(0, clickChildViewWithId(R.id.ViewComments)))
Thread.sleep(1000)
onView(first(withId(R.id.commentContainer)))
@ -185,7 +182,7 @@ class HomeFeedTest {
fun clickingViewCommentFails() {
//Open the comment section
onView(withId(R.id.list))
.perform(actionOnItemAtPosition<PostViewHolder>
.perform(actionOnItemAtPosition<StatusViewHolder>
(2, clickChildViewWithId(R.id.ViewComments)))
Thread.sleep(1000)
onView(withId(R.id.list)).check(matches(isDisplayed()))
@ -195,17 +192,17 @@ class HomeFeedTest {
fun postingACommentWorks() {
//Open the comment section
onView(withId(R.id.list))
.perform(actionOnItemAtPosition<PostViewHolder>
.perform(actionOnItemAtPosition<StatusViewHolder>
(0, clickChildViewWithId(R.id.commenter)))
onView(withId(R.id.list)).perform(slowSwipeUp(false))
Thread.sleep(1000)
onView(withId(R.id.list))
.perform(actionOnItemAtPosition<PostViewHolder>
.perform(actionOnItemAtPosition<StatusViewHolder>
(0, typeTextInViewWithId(R.id.editComment, "test")))
onView(withId(R.id.list))
.perform(actionOnItemAtPosition<PostViewHolder>
.perform(actionOnItemAtPosition<StatusViewHolder>
(0, clickChildViewWithId(R.id.submitComment)))
Thread.sleep(1000)
@ -215,14 +212,14 @@ class HomeFeedTest {
@Test
fun performClickOnSensitiveWarning() {
onView(withId(R.id.list)).perform(scrollToPosition<PostViewHolder>(1))
onView(withId(R.id.list)).perform(scrollToPosition<StatusViewHolder>(1))
Thread.sleep(1000)
onView(second(withId(R.id.sensitiveWarning))).check(matches(withEffectiveVisibility(Visibility.VISIBLE)))
Thread.sleep(1000)
onView(withId(R.id.list))
.perform(actionOnItemAtPosition<PostViewHolder>
.perform(actionOnItemAtPosition<StatusViewHolder>
(1, clickChildViewWithId(R.id.sensitiveWarning)))
Thread.sleep(1000)
@ -232,14 +229,14 @@ class HomeFeedTest {
@Test
fun performClickOnSensitiveWarningTabs() {
onView(withId(R.id.list)).perform(scrollToPosition<PostViewHolder>(0))
onView(withId(R.id.list)).perform(scrollToPosition<StatusViewHolder>(0))
Thread.sleep(1000)
onView(first(withId(R.id.sensitiveWarning))).check(matches(withEffectiveVisibility(Visibility.VISIBLE)))
Thread.sleep(1000)
onView(withId(R.id.list))
.perform(actionOnItemAtPosition<PostViewHolder>
.perform(actionOnItemAtPosition<StatusViewHolder>
(0, clickChildViewWithId(R.id.sensitiveWarning)))
Thread.sleep(1000)
@ -254,16 +251,16 @@ class HomeFeedTest {
//Remove sensitive media warning
onView(withId(R.id.list))
.perform(actionOnItemAtPosition<PostViewHolder>
.perform(actionOnItemAtPosition<StatusViewHolder>
(0, clickChildViewWithId(R.id.sensitiveWarning)))
Thread.sleep(100)
//Like the post
onView(withId(R.id.list))
.perform(actionOnItemAtPosition<PostViewHolder>
.perform(actionOnItemAtPosition<StatusViewHolder>
(0, clickChildViewWithId(R.id.postPicture)))
onView(withId(R.id.list))
.perform(actionOnItemAtPosition<PostViewHolder>
.perform(actionOnItemAtPosition<StatusViewHolder >
(0, clickChildViewWithId(R.id.postPicture)))
//...
Thread.sleep(100)

View File

@ -24,9 +24,9 @@ import androidx.test.espresso.matcher.ViewMatchers
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.rule.ActivityTestRule
import com.h.pixeldroid.db.AppDatabase
import com.h.pixeldroid.db.InstanceDatabaseEntity
import com.h.pixeldroid.db.UserDatabaseEntity
import com.h.pixeldroid.fragments.feeds.postFeeds.PostViewHolder
import com.h.pixeldroid.db.entities.InstanceDatabaseEntity
import com.h.pixeldroid.db.entities.UserDatabaseEntity
import com.h.pixeldroid.fragments.StatusViewHolder
import com.h.pixeldroid.objects.Account
import com.h.pixeldroid.objects.Account.Companion.ACCOUNT_TAG
import com.h.pixeldroid.testUtility.MockServer
@ -112,7 +112,7 @@ class IntentTest {
//Click the mention
Espresso.onView(ViewMatchers.withId(R.id.list))
.perform(RecyclerViewActions.actionOnItemAtPosition<PostViewHolder>
.perform(RecyclerViewActions.actionOnItemAtPosition<StatusViewHolder>
(0, clickClickableSpanInDescription("@Dobios")))
//Wait a bit

View File

@ -17,8 +17,8 @@ import androidx.test.espresso.matcher.ViewMatchers.isDisplayed
import androidx.test.espresso.matcher.ViewMatchers.withId
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.h.pixeldroid.db.AppDatabase
import com.h.pixeldroid.db.InstanceDatabaseEntity
import com.h.pixeldroid.db.UserDatabaseEntity
import com.h.pixeldroid.db.entities.InstanceDatabaseEntity
import com.h.pixeldroid.db.entities.UserDatabaseEntity
import com.h.pixeldroid.testUtility.MockServer
import com.h.pixeldroid.testUtility.clearData
import com.h.pixeldroid.testUtility.initDB

View File

@ -1,38 +1,6 @@
package com.h.pixeldroid
import android.content.Context
import android.graphics.Color
import android.graphics.ColorMatrix
import androidx.test.core.app.ActivityScenario
import androidx.test.core.app.ApplicationProvider
import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.action.ViewActions
import androidx.test.espresso.action.ViewActions.click
import androidx.test.espresso.assertion.ViewAssertions.matches
import androidx.test.espresso.contrib.RecyclerViewActions.actionOnItemAtPosition
import androidx.test.espresso.matcher.ViewMatchers.*
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.google.android.material.tabs.TabLayout
import com.h.pixeldroid.db.AppDatabase
import com.h.pixeldroid.db.InstanceDatabaseEntity
import com.h.pixeldroid.db.UserDatabaseEntity
import com.h.pixeldroid.fragments.feeds.postFeeds.PostViewHolder
import com.h.pixeldroid.testUtility.CustomMatchers.Companion.clickChildViewWithId
import com.h.pixeldroid.testUtility.CustomMatchers.Companion.first
import com.h.pixeldroid.testUtility.CustomMatchers.Companion.second
import com.h.pixeldroid.testUtility.MockServer
import com.h.pixeldroid.testUtility.clearData
import com.h.pixeldroid.testUtility.initDB
import com.h.pixeldroid.utils.PostUtils.Companion.censorColorMatrix
import com.h.pixeldroid.utils.PostUtils.Companion.uncensorColorMatrix
import junit.framework.Assert.assertEquals
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
/*
@RunWith(AndroidJUnit4::class)

View File

@ -1,40 +1,5 @@
package com.h.pixeldroid
import android.Manifest
import android.content.Context
import android.content.Intent
import android.graphics.Bitmap
import android.graphics.Color
import android.util.Log
import android.view.View.VISIBLE
import androidx.core.net.toUri
import androidx.test.core.app.ActivityScenario
import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.action.ViewActions.click
import androidx.test.espresso.assertion.ViewAssertions.matches
import androidx.test.espresso.contrib.RecyclerViewActions
import androidx.test.espresso.matcher.ViewMatchers.isDisplayed
import androidx.test.espresso.matcher.ViewMatchers.withId
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.rule.GrantPermissionRule
import com.h.pixeldroid.adapters.ThumbnailAdapter
import com.h.pixeldroid.db.AppDatabase
import com.h.pixeldroid.db.InstanceDatabaseEntity
import com.h.pixeldroid.db.UserDatabaseEntity
import com.h.pixeldroid.testUtility.CustomMatchers
import com.h.pixeldroid.testUtility.MockServer
import com.h.pixeldroid.testUtility.clearData
import com.h.pixeldroid.testUtility.initDB
import kotlinx.android.synthetic.main.activity_post_creation.*
import org.hamcrest.Matchers.not
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 java.io.File
/*
@RunWith(AndroidJUnit4::class)
class PostCreationActivityTest {

View File

@ -17,8 +17,8 @@ import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.rule.GrantPermissionRule
import com.h.pixeldroid.db.AppDatabase
import com.h.pixeldroid.db.InstanceDatabaseEntity
import com.h.pixeldroid.db.UserDatabaseEntity
import com.h.pixeldroid.db.entities.InstanceDatabaseEntity
import com.h.pixeldroid.db.entities.UserDatabaseEntity
import com.h.pixeldroid.testUtility.MockServer
import com.h.pixeldroid.testUtility.clearData
import com.h.pixeldroid.testUtility.initDB

View File

@ -14,8 +14,8 @@ import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.platform.app.InstrumentationRegistry.getInstrumentation
import com.h.pixeldroid.db.AppDatabase
import com.h.pixeldroid.db.InstanceDatabaseEntity
import com.h.pixeldroid.db.UserDatabaseEntity
import com.h.pixeldroid.db.entities.InstanceDatabaseEntity
import com.h.pixeldroid.db.entities.UserDatabaseEntity
import com.h.pixeldroid.objects.*
import com.h.pixeldroid.testUtility.MockServer
import com.h.pixeldroid.testUtility.clearData

View File

@ -25391,7 +25391,254 @@
</div>
<div class="library">
<!-- https://opensource.org/licenses/Apache-2.0 -->
<h1 class="title">kotlinx-coroutines-core</h1>
<h1 class="title">viewbinding</h1>
<p class="notice">Copyright &copy; Google Inc. All rights reserved.</p>
<input type="checkbox"><label></label>
<div class="license">
<h2>
Apache License
<br/>
Version 2.0, January 2004
<br/>
http://www.apache.org/licenses/
</h2>
<p>TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION</p>
<h2>1. Definitions.</h2>
<p>
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
</p>
<p>
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
</p>
<p>
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
</p>
<p>
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
</p>
<p>
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
</p>
<p>
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
</p>
<p>
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
</p>
<p>
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
</p>
<p>
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
</p>
<p>
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
</p>
<div class="block">
<h2 class="inline">2. Grant of Copyright License.</h2>
<p class="inline">
Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
</p>
</div>
<div class="block">
<h2 class="inline">3. Grant of Patent License.</h2>
<p class="inline">
Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
</p>
</div>
<div class="block">
<h2 class="inline">4. Redistribution.</h2>
<p class="inline">
You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
</p>
</div>
<ul class="low-alpha">
<li>
You must give any other recipients of the Work or
Derivative Works a copy of this License; and
</li>
<li>
You must cause any modified files to carry prominent notices
stating that You changed the files; and
</li>
<li>
You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of |
the Derivative Works; and
</li>
<li>
If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
</li>
</ul>
<p>
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
</p>
<div class="block">
<h2 class="inline">5. Submission of Contributions.</h2>
<p class="inline">
Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
</p>
</div>
<div class="block">
<h2 class="inline">6. Trademarks.</h2>
<p class="inline">
This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
</p>
</div>
<div class="block">
<h2 class="inline">7. Disclaimer of Warranty.</h2>
<p class="inline">
Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
</p>
</div>
<div class="block">
<h2 class="inline">8. Limitation of Liability.</h2>
<p class="inline">
In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
</p>
</div>
<div class="block">
<h2 class="inline">9. Accepting Warranty or Additional Liability.</h2>
<p class="inline">
While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
</p>
</div>
<p>END OF TERMS AND CONDITIONS</p>
<h1>APPENDIX: How to apply the Apache License to your work.</h1>
<p>
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
</p>
<pre>Copyright [yyyy] [name of copyright owner]&#x000A;&#x000A;Licensed under the Apache License, Version 2.0 (the "License");&#x000A;you may not use this file except in compliance with the License.&#x000A;You may obtain a copy of the License at&#x000A;&#x000A; http://www.apache.org/licenses/LICENSE-2.0&#x000A;&#x000A;Unless required by applicable law or agreed to in writing, software&#x000A;distributed under the License is distributed on an "AS IS" BASIS,&#x000A;WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.&#x000A;See the License for the specific language governing permissions and&#x000A;limitations under the License.</pre>
</div>
</div>
<div class="library">
<!-- https://opensource.org/licenses/Apache-2.0 -->
<h1 class="title">kotlinx-coroutines-core-jvm</h1>
<p class="notice">Copyright &copy; JetBrains s.r.o. and contributors. All rights reserved.</p>
<p><a href="https://github.com/Kotlin/kotlinx.coroutines">https://github.com/Kotlin/kotlinx.coroutines</a></p>
<input type="checkbox"><label></label>

View File

@ -1,20 +1,16 @@
package com.h.pixeldroid
import android.os.Bundle
import android.util.Log
import androidx.appcompat.app.AppCompatActivity
import com.h.pixeldroid.db.AppDatabase
import com.h.pixeldroid.di.PixelfedAPIHolder
import com.h.pixeldroid.fragments.feeds.AccountListFragment
import com.h.pixeldroid.fragments.feeds.uncachedFeeds.accountLists.AccountListFragment
import com.h.pixeldroid.objects.Account
import com.h.pixeldroid.objects.Account.Companion.ACCOUNT_ID_TAG
import com.h.pixeldroid.objects.Account.Companion.ACCOUNT_TAG
import com.h.pixeldroid.objects.Account.Companion.FOLLOWERS_TAG
import retrofit2.Call
import retrofit2.Callback
import retrofit2.Response
import javax.inject.Inject
class FollowsActivity : AppCompatActivity() {
private var followsFragment = AccountListFragment()
@Inject

View File

@ -8,21 +8,24 @@ import android.os.Bundle
import android.util.Log
import android.view.View
import android.widget.ImageView
import androidx.annotation.DrawableRes
import androidx.appcompat.app.AppCompatActivity
import androidx.core.content.ContextCompat
import androidx.core.view.GravityCompat
import androidx.fragment.app.Fragment
import androidx.paging.ExperimentalPagingApi
import androidx.viewpager2.adapter.FragmentStateAdapter
import com.bumptech.glide.Glide
import com.google.android.material.tabs.TabLayoutMediator
import com.h.pixeldroid.db.AppDatabase
import com.h.pixeldroid.db.UserDatabaseEntity
import com.h.pixeldroid.db.entities.HomeStatusDatabaseEntity
import com.h.pixeldroid.db.entities.PublicFeedStatusDatabaseEntity
import com.h.pixeldroid.db.entities.UserDatabaseEntity
import com.h.pixeldroid.di.PixelfedAPIHolder
import com.h.pixeldroid.fragments.CameraFragment
import com.h.pixeldroid.fragments.SearchDiscoverFragment
import com.h.pixeldroid.fragments.feeds.NotificationsFragment
import com.h.pixeldroid.fragments.feeds.OfflineFeedFragment
import com.h.pixeldroid.fragments.feeds.postFeeds.HomeTimelineFragment
import com.h.pixeldroid.fragments.feeds.postFeeds.PublicTimelineFragment
import com.h.pixeldroid.fragments.feeds.cachedFeeds.postFeeds.PostFeedFragment
import com.h.pixeldroid.fragments.feeds.cachedFeeds.notifications.NotificationsFragment
import com.h.pixeldroid.objects.Account
import com.h.pixeldroid.utils.DBUtils
import com.h.pixeldroid.utils.Utils.Companion.hasInternet
@ -40,11 +43,11 @@ import org.ligi.tracedroid.sending.TraceDroidEmailSender
import retrofit2.Call
import retrofit2.Callback
import retrofit2.Response
import java.lang.IllegalArgumentException
import javax.inject.Inject
class MainActivity : AppCompatActivity() {
private val searchDiscoverFragment: SearchDiscoverFragment = SearchDiscoverFragment()
@Inject
lateinit var db: AppDatabase
@Inject
@ -57,6 +60,7 @@ class MainActivity : AppCompatActivity() {
const val ADD_ACCOUNT_IDENTIFIER: Long = -13
}
@ExperimentalPagingApi
override fun onCreate(savedInstanceState: Bundle?) {
setTheme(R.style.AppTheme_NoActionBar)
super.onCreate(savedInstanceState)
@ -75,13 +79,19 @@ class MainActivity : AppCompatActivity() {
launchActivity(LoginActivity(), firstTime = true)
} else {
setupDrawer()
val tabs = arrayOf(
if (hasInternet(applicationContext)) HomeTimelineFragment()
else OfflineFeedFragment(),
searchDiscoverFragment,
val tabs: List<Fragment> = listOf(
PostFeedFragment<HomeStatusDatabaseEntity>()
.apply {
arguments = Bundle().apply { putBoolean("home", true) }
},
SearchDiscoverFragment(),
CameraFragment(),
NotificationsFragment(),
PublicTimelineFragment()
PostFeedFragment<PublicFeedStatusDatabaseEntity>()
.apply {
arguments = Bundle().apply { putBoolean("home", false) }
}
)
setupTabs(tabs)
}
@ -123,7 +133,7 @@ class MainActivity : AppCompatActivity() {
override fun placeholder(ctx: Context, tag: String?): Drawable {
if (tag == DrawerImageLoader.Tags.PROFILE.name || tag == DrawerImageLoader.Tags.PROFILE_DRAWER_ITEM.name) {
return ctx.getDrawable(R.drawable.ic_default_user)!!
return ContextCompat.getDrawable(ctx, R.drawable.ic_default_user)!!
}
return super.placeholder(ctx, tag)
@ -264,24 +274,30 @@ class MainActivity : AppCompatActivity() {
}
private fun setupTabs(tab_array: Array<Fragment>){
private fun setupTabs(tab_array: List<Fragment>){
view_pager.adapter = object : FragmentStateAdapter(this) {
override fun createFragment(position: Int): Fragment {
return tab_array[position]
}
override fun getItemCount(): Int {
return 5
return tab_array.size
}
}
//Keep the tabs active to prevent reloads and stutters
view_pager.offscreenPageLimit = tab_array.size - 1
TabLayoutMediator(tabs, view_pager) { tab, position ->
when(position){
0 -> tab.icon = getDrawable(R.drawable.ic_home_white_24dp)
1 -> tab.icon = getDrawable(R.drawable.ic_search_white_24dp)
2 -> tab.icon = getDrawable(R.drawable.ic_photo_camera_white_24dp)
3 -> tab.icon = getDrawable(R.drawable.ic_heart)
4 -> tab.icon = getDrawable(R.drawable.ic_filter_black_24dp)
}
tab.icon = ContextCompat.getDrawable(applicationContext,
when(position){
0 -> R.drawable.ic_home_white_24dp
1 -> R.drawable.ic_search_white_24dp
2 -> R.drawable.ic_photo_camera_white_24dp
3 -> R.drawable.ic_heart
4 -> R.drawable.ic_filter_black_24dp
else -> throw IllegalArgumentException()
})
}.attach()
}

View File

@ -19,7 +19,7 @@ import com.bumptech.glide.Glide
import com.google.android.material.textfield.TextInputLayout
import com.h.pixeldroid.api.PixelfedAPI
import com.h.pixeldroid.db.AppDatabase
import com.h.pixeldroid.db.UserDatabaseEntity
import com.h.pixeldroid.db.entities.UserDatabaseEntity
import com.h.pixeldroid.di.PixelfedAPIHolder
import com.h.pixeldroid.interfaces.PostCreationListener
import com.h.pixeldroid.objects.Attachment

View File

@ -17,14 +17,13 @@ import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
import com.h.pixeldroid.adapters.ProfilePostsRecyclerViewAdapter
import com.h.pixeldroid.api.PixelfedAPI
import com.h.pixeldroid.db.AppDatabase
import com.h.pixeldroid.db.UserDatabaseEntity
import com.h.pixeldroid.db.entities.UserDatabaseEntity
import com.h.pixeldroid.di.PixelfedAPIHolder
import com.h.pixeldroid.objects.Account
import com.h.pixeldroid.objects.Relationship
import com.h.pixeldroid.objects.Status
import com.h.pixeldroid.utils.HtmlUtils.Companion.parseHTMLText
import com.h.pixeldroid.utils.ImageConverter
import kotlinx.android.synthetic.main.fragment_search.*
import retrofit2.Call
import retrofit2.Callback
import retrofit2.Response
@ -145,9 +144,9 @@ class ProfileActivity : AppCompatActivity() {
if(show){
motionLayout?.transitionToEnd()
} else {
findViewById<ProgressBar>(R.id.profileProgressBar).visibility = View.GONE
motionLayout?.transitionToStart()
}
findViewById<ProgressBar>(R.id.profileProgressBar).visibility = View.GONE
refreshLayout.isRefreshing = false
}

View File

@ -10,9 +10,9 @@ import androidx.viewpager2.adapter.FragmentStateAdapter
import androidx.viewpager2.widget.ViewPager2
import com.google.android.material.tabs.TabLayout
import com.google.android.material.tabs.TabLayoutMediator
import com.h.pixeldroid.fragments.feeds.search.SearchAccountFragment
import com.h.pixeldroid.fragments.feeds.search.SearchHashtagFragment
import com.h.pixeldroid.fragments.feeds.search.SearchPostsFragment
import com.h.pixeldroid.fragments.feeds.uncachedFeeds.search.SearchAccountFragment
import com.h.pixeldroid.fragments.feeds.uncachedFeeds.search.SearchHashtagFragment
import com.h.pixeldroid.fragments.feeds.uncachedFeeds.search.SearchPostsFragment
import com.h.pixeldroid.objects.Results
class SearchActivity : AppCompatActivity() {
@ -54,8 +54,7 @@ class SearchActivity : AppCompatActivity() {
private fun createSearchTabs(query: String): Array<Fragment>{
val searchFeedFragment =
SearchPostsFragment()
val searchFeedFragment = SearchPostsFragment()
val searchAccountListFragment =
SearchAccountFragment()
val searchHashtagFragment: Fragment = SearchHashtagFragment()

View File

@ -5,6 +5,7 @@ import io.reactivex.Observable
import io.reactivex.Single
import okhttp3.MultipartBody
import retrofit2.Call
import retrofit2.Response
import retrofit2.Retrofit
import retrofit2.adapter.rxjava2.RxJava2CallAdapterFactory
import retrofit2.converter.gson.GsonConverterFactory
@ -153,16 +154,16 @@ interface PixelfedAPI {
) : Call<Context>
@GET("/api/v1/timelines/public")
fun timelinePublic(
suspend fun timelinePublic(
@Query("local") local: Boolean? = null,
@Query("max_id") max_id: String? = null,
@Query("since_id") since_id: String? = null,
@Query("min_id") min_id: String? = null,
@Query("limit") limit: String? = null
): Call<List<Status>>
): List<Status>
@GET("/api/v1/timelines/home")
fun timelineHome(
suspend fun timelineHome(
//The authorization header needs to be of the form "Bearer <token>"
@Header("Authorization") authorization: String,
@Query("max_id") max_id: String? = null,
@ -170,10 +171,10 @@ interface PixelfedAPI {
@Query("min_id") min_id: String? = null,
@Query("limit") limit: String? = null,
@Query("local") local: Boolean? = null
): Call<List<Status>>
): List<Status>
@GET("/api/v2/search")
fun search(
suspend fun search(
//The authorization header needs to be of the form "Bearer <token>"
@Header("Authorization") authorization: String,
@Query("account_id") account_id: String? = null,
@ -186,24 +187,19 @@ interface PixelfedAPI {
@Query("limit") limit: String? = null,
@Query("offset") offset: String? = null,
@Query("following") following: Boolean? = null
): Call<Results>
): Results
/*
Note: as of 0.10.8, Pixelfed does not seem to respect the Mastodon API documentation,
you *need* to pass one of the so-called "optional" arguments. See:
https://github.com/pixelfed/pixelfed/blob/dev/app/Http/Controllers/Api/ApiV1Controller.php
An example that works: specify min_id as 1 (not 0 though)
*/
@GET("/api/v1/notifications")
fun notifications(
suspend fun notifications(
//The authorization header needs to be of the form "Bearer <token>"
@Header("Authorization") authorization: String,
@Query("max_id") max_id: String? = null,
@Query("since_id") since_id: String? = null,
@Query("min_id") min_id: String? = null,
@Query("exclude_types") limit: String? = null,
@Query("account_id") exclude_types: Boolean? = null
): Call<List<Notification>>
@Query("limit") limit: String? = null,
@Query("exclude_types") exclude_types: List<String>? = null,
@Query("account_id") account_id: Boolean? = null
): List<Notification>
@GET("/api/v1/accounts/verify_credentials")
fun verifyCredentials(
@ -224,24 +220,24 @@ interface PixelfedAPI {
) : Call<List<Relationship>>
@GET("/api/v1/accounts/{id}/followers")
fun followers(
suspend fun followers(
@Path("id") account_id: String,
@Header("Authorization") authorization: String,
@Query("max_id") max_id: String? = null,
@Query("since_id") since_id: String? = null,
@Query("limit") limit: Number? = null,
@Query("page") page: String? = null
) : Call<List<Account>>
) : Response<List<Account>>
@GET("/api/v1/accounts/{id}/following")
fun following(
suspend fun following(
@Path("id") account_id: String,
@Header("Authorization") authorization: String,
@Query("max_id") max_id: String? = null,
@Query("since_id") since_id: String? = null,
@Query("limit") limit: Number? = 40,
@Query("page") page: String? = null
) : Call<List<Account>>
) : Response<List<Account>>
@GET("/api/v1/accounts/{id}")
fun getAccount(

View File

@ -3,11 +3,22 @@ package com.h.pixeldroid.db
import androidx.room.Database
import androidx.room.RoomDatabase
import androidx.room.TypeConverters
import com.h.pixeldroid.db.dao.*
import com.h.pixeldroid.db.dao.feedContent.NotificationDao
import com.h.pixeldroid.db.dao.feedContent.posts.HomePostDao
import com.h.pixeldroid.db.dao.feedContent.posts.PublicPostDao
import com.h.pixeldroid.db.entities.HomeStatusDatabaseEntity
import com.h.pixeldroid.db.entities.InstanceDatabaseEntity
import com.h.pixeldroid.db.entities.PublicFeedStatusDatabaseEntity
import com.h.pixeldroid.db.entities.UserDatabaseEntity
import com.h.pixeldroid.objects.Notification
@Database(entities = [
InstanceDatabaseEntity::class,
UserDatabaseEntity::class,
PostDatabaseEntity::class
HomeStatusDatabaseEntity::class,
PublicFeedStatusDatabaseEntity::class,
Notification::class
],
version = 1
)
@ -15,5 +26,7 @@ import androidx.room.TypeConverters
abstract class AppDatabase : RoomDatabase() {
abstract fun instanceDao(): InstanceDao
abstract fun userDao(): UserDao
abstract fun postDao(): PostDao
abstract fun homePostDao(): HomePostDao
abstract fun publicPostDao(): PublicPostDao
abstract fun notificationDao(): NotificationDao
}

View File

@ -2,7 +2,9 @@ package com.h.pixeldroid.db
import androidx.room.TypeConverter
import com.google.gson.Gson
import java.util.Date
import com.google.gson.reflect.TypeToken
import com.h.pixeldroid.objects.*
import java.util.*
class Converters {
@TypeConverter
@ -16,7 +18,113 @@ class Converters {
fun dateToJson(date: Date): String = Gson().toJson(date)
@TypeConverter
fun jsontoDate(json: String): Date = Gson().fromJson(json, Date::class.java)
fun jsonToDate(json: String): Date = Gson().fromJson(json, Date::class.java)
@TypeConverter
fun accountToJson(account: Account): String = Gson().toJson(account)
@TypeConverter
fun jsonToAccount(json: String): Account = Gson().fromJson(json, Account::class.java)
@TypeConverter
fun statusToJson(status: Status?): String = Gson().toJson(status)
@TypeConverter
fun jsonToStatus(json: String): Status? = Gson().fromJson(json, Status::class.java)
@TypeConverter
fun notificationTypeToJson(type: Notification.NotificationType?): String = Gson().toJson(type)
@TypeConverter
fun jsonToNotificationType(json: String): Notification.NotificationType? = Gson().fromJson(
json,
Notification.NotificationType::class.java
)
@TypeConverter
fun applicationToJson(type: Application?): String = Gson().toJson(type)
@TypeConverter
fun jsonToApplication(json: String): Application? = Gson().fromJson(
json,
Application::class.java
)
@TypeConverter
fun cardToJson(type: Card?): String = Gson().toJson(type)
@TypeConverter
fun jsonToCard(json: String): Card? = Gson().fromJson(json, Card::class.java)
@TypeConverter
fun attachmentToJson(type: Attachment?): String = Gson().toJson(type)
@TypeConverter
fun jsonToAttachment(json: String): Attachment? = Gson().fromJson(json, Attachment::class.java)
@TypeConverter
fun attachmentListToJson(type: List<Attachment>?): String {
val listType = object : TypeToken<List<Attachment?>?>() {}.type
return Gson().toJson(type, listType)
}
@TypeConverter
fun jsonToAttachmentList(json: String): List<Attachment>? {
val listType = object : TypeToken<List<Attachment?>?>() {}.type
return Gson().fromJson(json, listType)
}
@TypeConverter
fun mentionListToJson(type: List<Mention>?): String {
val listType = object : TypeToken<List<Mention?>?>() {}.type
return Gson().toJson(type, listType)
}
@TypeConverter
fun jsonToMentionList(json: String): List<Mention>? {
val listType = object : TypeToken<List<Mention?>?>() {}.type
return Gson().fromJson(json, listType)
}
@TypeConverter
fun emojiListToJson(type: List<Emoji>?): String {
val listType = object : TypeToken<List<Emoji?>?>() {}.type
return Gson().toJson(type, listType)
}
@TypeConverter
fun jsonToEmojiList(json: String): List<Emoji>? {
val listType = object : TypeToken<List<Emoji?>?>() {}.type
return Gson().fromJson(json, listType)
}
@TypeConverter
fun tagListToJson(type: List<Tag>?): String {
val listType = object : TypeToken<List<Tag?>?>() {}.type
return Gson().toJson(type, listType)
}
@TypeConverter
fun jsonToTagList(json: String): List<Tag>? {
val listType = object : TypeToken<List<Tag?>?>() {}.type
return Gson().fromJson(json, listType)
}
@TypeConverter
fun pollToJson(type: Poll?): String = Gson().toJson(type)
@TypeConverter
fun jsonToPoll(json: String): Poll? = Gson().fromJson(json, Poll::class.java)
@TypeConverter
fun visibilityToJson(type: Status.Visibility?): String = Gson().toJson(type)
@TypeConverter
fun jsonToVisibility(json: String): Status.Visibility? = Gson().fromJson(
json,
Status.Visibility::class.java
)
}

View File

@ -1,28 +0,0 @@
package com.h.pixeldroid.db
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
@Dao
interface PostDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
fun insertPost(post: PostDatabaseEntity)
@Query("SELECT COUNT(*) FROM posts WHERE user_id=:userId AND instance_uri=:instanceUri")
fun numberOfPosts(userId: String, instanceUri: String): Int
@Query("SELECT * FROM posts WHERE user_id=:user AND instance_uri=:instanceUri ORDER BY date DESC")
fun getAll(user: String, instanceUri: String): List<PostDatabaseEntity>
@Query("SELECT COUNT(*) FROM posts WHERE uri=:uri AND user_id=:userId AND instance_uri=:instanceUri")
fun count(uri: String, userId: String, instanceUri: String): Int
@Query(
"""DELETE FROM posts WHERE uri IN (
SELECT uri FROM posts ORDER BY date ASC LIMIT :nPosts
)"""
)
fun removeOlderPosts(nPosts: Int)
}

View File

@ -1,34 +0,0 @@
package com.h.pixeldroid.db
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.Index
import java.util.Date
@Entity(
tableName = "posts",
primaryKeys = ["uri", "user_id", "instance_uri"],
foreignKeys = [ForeignKey(
entity = UserDatabaseEntity::class,
parentColumns = arrayOf("user_id", "instance_uri"),
childColumns = arrayOf("user_id", "instance_uri"),
onUpdate = ForeignKey.CASCADE,
onDelete = ForeignKey.CASCADE
)],
indices = [Index(value = ["user_id"])]
)
data class PostDatabaseEntity (
var uri: String,
var user_id: String,
var instance_uri: String,
var account_profile_picture: String,
var account_name: String,
var media_urls: List<String>,
var favourite_count: Int,
var reply_count: Int,
var share_count: Int,
var description: String,
var date: Date,
var likes: Int,
var shares: Int
)

View File

@ -1,9 +1,10 @@
package com.h.pixeldroid.db
package com.h.pixeldroid.db.dao
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import com.h.pixeldroid.db.entities.InstanceDatabaseEntity
@Dao
interface InstanceDao {

View File

@ -1,9 +1,10 @@
package com.h.pixeldroid.db
package com.h.pixeldroid.db.dao
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import com.h.pixeldroid.db.entities.UserDatabaseEntity
@Dao
interface UserDao {

View File

@ -0,0 +1,17 @@
package com.h.pixeldroid.db.dao.feedContent
import androidx.paging.PagingSource
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import com.h.pixeldroid.objects.FeedContentDatabase
interface FeedContentDao<T: FeedContentDatabase>{
fun feedContent(userId: String, instanceUri: String): PagingSource<Int, T>
suspend fun clearFeedContent()
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertAll(feedContent: List<T>)
}

View File

@ -0,0 +1,17 @@
package com.h.pixeldroid.db.dao.feedContent
import androidx.paging.PagingSource
import androidx.room.Dao
import androidx.room.Query
import com.h.pixeldroid.objects.Notification
@Dao
interface NotificationDao: FeedContentDao<Notification> {
@Query("DELETE FROM notifications")
override suspend fun clearFeedContent()
@Query("""SELECT * FROM notifications WHERE user_id=:userId AND instance_uri=:instanceUri
ORDER BY CAST(created_at AS FLOAT) DESC""")
override fun feedContent(userId: String, instanceUri: String): PagingSource<Int, Notification>
}

View File

@ -0,0 +1,18 @@
package com.h.pixeldroid.db.dao.feedContent.posts
import androidx.paging.PagingSource
import androidx.room.Dao
import androidx.room.Query
import com.h.pixeldroid.db.dao.feedContent.FeedContentDao
import com.h.pixeldroid.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)""")
override fun feedContent(userId: String, instanceUri: String): PagingSource<Int, HomeStatusDatabaseEntity>
@Query("DELETE FROM homePosts")
override suspend fun clearFeedContent()
}

View File

@ -0,0 +1,18 @@
package com.h.pixeldroid.db.dao.feedContent.posts
import androidx.paging.PagingSource
import androidx.room.Dao
import androidx.room.Query
import com.h.pixeldroid.db.dao.feedContent.FeedContentDao
import com.h.pixeldroid.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)""")
override fun feedContent(userId: String, instanceUri: String): PagingSource<Int, PublicFeedStatusDatabaseEntity>
@Query("DELETE FROM publicPosts")
override suspend fun clearFeedContent()
}

View File

@ -0,0 +1,95 @@
package com.h.pixeldroid.db.entities
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.Ignore
import androidx.room.Index
import com.h.pixeldroid.objects.*
import java.util.*
@Entity(
tableName = "homePosts",
primaryKeys = ["id", "user_id", "instance_uri"],
foreignKeys = [ForeignKey(
entity = UserDatabaseEntity::class,
parentColumns = arrayOf("user_id", "instance_uri"),
childColumns = arrayOf("user_id", "instance_uri"),
onUpdate = ForeignKey.CASCADE,
onDelete = ForeignKey.CASCADE
)],
indices = [Index(value = ["user_id", "instance_uri"])]
)
class HomeStatusDatabaseEntity(
override var user_id: String,
override var instance_uri: String,
status: Status
): Status(
status.id,
status.uri,
status.created_at,
status.account,
status.content,
status.visibility,
status.sensitive,
status.spoiler_text,
status.media_attachments,
status.application,
status.mentions,
status.tags,
status.emojis,
status.reblogs_count,
status.favourites_count,
status.replies_count,
status.url,
status.in_reply_to_id,
status.in_reply_to_account,
status.reblog,
status.poll,
status.card,
status.language,
status.text,
status.favourited,
status.reblogged,
status.muted,
status.bookmarked,
status.pinned
), FeedContentDatabase {
//Constructor to make Room happy. This sucks, and I know it.
constructor(id: String,
uri: String? = "",
created_at: Date? = Date(0),
account: Account?,
content: String? = "",
visibility: Visibility? = Visibility.public,
sensitive: Boolean? = false,
spoiler_text: String? = "",
media_attachments: List<Attachment>? = null,
application: Application? = null,
mentions: List<Mention>? = null,
tags: List<Tag>? = null,
emojis: List<Emoji>? = null,
reblogs_count: Int? = 0,
favourites_count: Int? = 0,
replies_count: Int? = 0,
url: String? = null,
in_reply_to_id: String? = null,
in_reply_to_account: String? = null,
reblog: Status? = null,
poll: Poll? = null,
card: Card? = null,
language: String? = null,
text: String? = null,
favourited: Boolean? = false,
reblogged: Boolean? = false,
muted: Boolean? = false,
bookmarked: Boolean? = false,
pinned: Boolean? = false,
user_id: String,
instance_uri: String): this(user_id, instance_uri, Status(id, uri, created_at, account, content, visibility, sensitive, spoiler_text, media_attachments, application, mentions, tags, emojis, reblogs_count, favourites_count, replies_count, url, in_reply_to_id, in_reply_to_account, reblog, poll, card, language, text, favourited, reblogged, muted, bookmarked, pinned)
)
}

View File

@ -1,4 +1,4 @@
package com.h.pixeldroid.db
package com.h.pixeldroid.db.entities
import androidx.room.Entity
import androidx.room.PrimaryKey

View File

@ -0,0 +1,94 @@
package com.h.pixeldroid.db.entities
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.Index
import com.h.pixeldroid.objects.*
import java.util.*
@Entity(
tableName = "publicPosts",
primaryKeys = ["id", "user_id", "instance_uri"],
foreignKeys = [ForeignKey(
entity = UserDatabaseEntity::class,
parentColumns = arrayOf("user_id", "instance_uri"),
childColumns = arrayOf("user_id", "instance_uri"),
onUpdate = ForeignKey.CASCADE,
onDelete = ForeignKey.CASCADE
)],
indices = [Index(value = ["user_id", "instance_uri"])]
)
class PublicFeedStatusDatabaseEntity(
override var user_id: String,
override var instance_uri: String,
status: Status
): Status(
status.id,
status.uri,
status.created_at,
status.account,
status.content,
status.visibility,
status.sensitive,
status.spoiler_text,
status.media_attachments,
status.application,
status.mentions,
status.tags,
status.emojis,
status.reblogs_count,
status.favourites_count,
status.replies_count,
status.url,
status.in_reply_to_id,
status.in_reply_to_account,
status.reblog,
status.poll,
status.card,
status.language,
status.text,
status.favourited,
status.reblogged,
status.muted,
status.bookmarked,
status.pinned
), FeedContentDatabase {
//Constructor to make Room happy. This sucks, and I know it.
constructor(id: String,
uri: String? = "",
created_at: Date? = Date(0),
account: Account?,
content: String? = "",
visibility: Visibility? = Visibility.public,
sensitive: Boolean? = false,
spoiler_text: String? = "",
media_attachments: List<Attachment>? = null,
application: Application? = null,
mentions: List<Mention>? = null,
tags: List<Tag>? = null,
emojis: List<Emoji>? = null,
reblogs_count: Int? = 0,
favourites_count: Int? = 0,
replies_count: Int? = 0,
url: String? = null,
in_reply_to_id: String? = null,
in_reply_to_account: String? = null,
reblog: Status? = null,
poll: Poll? = null,
card: Card? = null,
language: String? = null,
text: String? = null,
favourited: Boolean? = false,
reblogged: Boolean? = false,
muted: Boolean? = false,
bookmarked: Boolean? = false,
pinned: Boolean? = false,
user_id: String,
instance_uri: String): this(user_id, instance_uri, Status(id, uri, created_at, account, content, visibility, sensitive, spoiler_text, media_attachments, application, mentions, tags, emojis, reblogs_count, favourites_count, replies_count, url, in_reply_to_id, in_reply_to_account, reblog, poll, card, language, text, favourited, reblogged, muted, bookmarked, pinned)
)
}

View File

@ -1,8 +1,9 @@
package com.h.pixeldroid.db
package com.h.pixeldroid.db.entities
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.Index
import com.h.pixeldroid.db.entities.InstanceDatabaseEntity
@Entity(
tableName = "users",

View File

@ -4,10 +4,10 @@ import android.app.Application
import android.content.Context
import com.h.pixeldroid.*
import com.h.pixeldroid.db.AppDatabase
import com.h.pixeldroid.fragments.BaseFragment
import com.h.pixeldroid.fragments.PostFragment
import com.h.pixeldroid.fragments.SearchDiscoverFragment
import com.h.pixeldroid.fragments.feeds.FeedFragment
import com.h.pixeldroid.fragments.feeds.OfflineFeedFragment
import com.h.pixeldroid.fragments.feeds.cachedFeeds.notifications.NotificationsFragment
import dagger.Component
import javax.inject.Singleton
@ -18,8 +18,6 @@ import javax.inject.Singleton
interface ApplicationComponent {
fun inject(application: Pixeldroid?)
fun inject(activity: LoginActivity?)
fun inject(feedFragment: FeedFragment)
fun inject(activity: FollowsActivity?)
fun inject(activity: PostActivity?)
fun inject(activity: PostCreationActivity?)
fun inject(activity: ProfileActivity?)
@ -27,7 +25,11 @@ interface ApplicationComponent {
fun inject(activity: ReportActivity?)
fun inject(fragment: PostFragment)
fun inject(fragment: SearchDiscoverFragment)
fun inject(fragment: OfflineFeedFragment)
fun inject(fragment: NotificationsFragment)
fun inject(feedFragment: BaseFragment)
fun inject(followsActivity: FollowsActivity)
val context: Context?
val application: Application?

View File

@ -0,0 +1,26 @@
package com.h.pixeldroid.fragments
import android.os.Bundle
import androidx.fragment.app.Fragment
import com.h.pixeldroid.Pixeldroid
import com.h.pixeldroid.db.AppDatabase
import com.h.pixeldroid.di.PixelfedAPIHolder
import javax.inject.Inject
/**
* Base Fragment, for dependency injection and other things common to a lot of the fragments
*/
open class BaseFragment: Fragment() {
@Inject
lateinit var apiHolder: PixelfedAPIHolder
@Inject
lateinit var db: AppDatabase
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
(requireActivity().application as Pixeldroid).getAppComponent().inject(this)
}
}

View File

@ -1,81 +0,0 @@
package com.h.pixeldroid.fragments
import android.graphics.Color
import android.graphics.drawable.ColorDrawable
import android.os.Bundle
import androidx.fragment.app.Fragment
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.ImageView
import com.bumptech.glide.Glide
import com.google.android.material.snackbar.Snackbar
import com.h.pixeldroid.R
import kotlinx.android.synthetic.main.fragment_image.*
// the fragment initialization parameters, e.g. ARG_ITEM_NUMBER
private const val IMG_URL = "imgurl"
private const val IMG_DESCRIPTION = "imgdescription"
private const val RQST_BLDR = "rqstbldr"
/**
* A simple [Fragment] subclass.
* Use the [ImageFragment.newInstance] factory method to
* create an instance of this fragment.
*/
class ImageFragment : Fragment() {
private lateinit var imgUrl: String
private lateinit var imgDescription: String
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
arguments?.let {
imgUrl = it.getString(IMG_URL)!!
imgDescription = it.getString(IMG_DESCRIPTION)!!.ifEmpty { getString(R.string.no_description) }
}
}
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
val view = inflater.inflate(R.layout.fragment_image, container, false)
view.findViewById<ImageView>(R.id.imageImageView).setOnLongClickListener {
Snackbar.make(it, imgDescription, Snackbar.LENGTH_SHORT).show()
true
}
// Inflate the layout for this fragment
return view
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
//Load the image into to view
Glide.with(this)
.asDrawable().fitCenter()
.placeholder(ColorDrawable(Color.GRAY))
.load(imgUrl)
.into(view.findViewById(R.id.imageImageView)!!)
imageImageView.contentDescription = imgDescription
}
companion object {
/**
* Use this factory method to create a new instance of
* this fragment using the provided parameters.
*
* @param imageUrl the url of the image we want to create a fragment for
* @return A new instance of fragment ImageFragment.
*/
@JvmStatic
fun newInstance(imageUrl: String, imageDescription: String) =
ImageFragment().apply {
arguments = Bundle().apply {
putString(IMG_URL, imageUrl)
putString(IMG_DESCRIPTION, imageDescription)
}
}
}
}

View File

@ -1,59 +1,42 @@
package com.h.pixeldroid.fragments
import android.graphics.Color
import android.graphics.drawable.ColorDrawable
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.Fragment
import com.bumptech.glide.Glide
import com.h.pixeldroid.Pixeldroid
import com.h.pixeldroid.R
import com.h.pixeldroid.api.PixelfedAPI
import com.h.pixeldroid.db.AppDatabase
import com.h.pixeldroid.di.PixelfedAPIHolder
import com.h.pixeldroid.fragments.feeds.postFeeds.PostViewHolder
import com.h.pixeldroid.objects.Status
import com.h.pixeldroid.objects.Status.Companion.DOMAIN_TAG
import com.h.pixeldroid.objects.Status.Companion.POST_TAG
import javax.inject.Inject
class PostFragment : Fragment() {
class PostFragment : BaseFragment() {
@Inject
lateinit var db: AppDatabase
private lateinit var statusDomain: String
private var currentStatus: Status? = null
@Inject
lateinit var apiHolder: PixelfedAPIHolder
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
currentStatus = arguments?.getSerializable(POST_TAG) as Status?
statusDomain = arguments?.getString(DOMAIN_TAG)!!
}
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
val currentStatus = arguments?.getSerializable(POST_TAG) as Status?
val statusDomain = arguments?.getString(DOMAIN_TAG)!!
val root: View = inflater.inflate(R.layout.post_fragment, container, false)
val picRequest = Glide.with(this)
.asDrawable().fitCenter()
.placeholder(ColorDrawable(Color.GRAY))
(requireActivity().application as Pixeldroid).getAppComponent().inject(this)
val user = db.userDao().getActiveUser()!!
val user = db.userDao().getActiveUser()
val accessToken = user.accessToken
val api = apiHolder.api ?: apiHolder.setDomain(user.instance_uri)
val accessToken = user?.accessToken.orEmpty()
val api = apiHolder.api ?: apiHolder.setDomainToCurrentUser(db)
val holder = StatusViewHolder(root)
currentStatus?.setupPost(root, picRequest, this, statusDomain, true)
val holder = PostViewHolder(
root,
root.context
)
currentStatus?.activateButtons(holder, api, "Bearer $accessToken")
holder.bind(currentStatus, statusDomain, api, "Bearer $accessToken")
return root

View File

@ -0,0 +1,731 @@
package com.h.pixeldroid.fragments
import android.Manifest
import android.content.Intent
import android.graphics.Color
import android.graphics.Typeface
import android.graphics.drawable.ColorDrawable
import android.graphics.drawable.Drawable
import android.text.method.LinkMovementMethod
import android.util.Log
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.*
import androidx.core.content.ContextCompat
import androidx.recyclerview.widget.RecyclerView
import androidx.viewpager2.widget.ViewPager2
import at.connyduck.sparkbutton.SparkButton
import com.bumptech.glide.Glide
import com.bumptech.glide.RequestBuilder
import com.google.android.material.snackbar.Snackbar
import com.google.android.material.tabs.TabLayoutMediator
import com.h.pixeldroid.R
import com.h.pixeldroid.ReportActivity
import com.h.pixeldroid.api.PixelfedAPI
import com.h.pixeldroid.objects.Attachment
import com.h.pixeldroid.objects.Context
import com.h.pixeldroid.objects.Status
import com.h.pixeldroid.utils.HtmlUtils
import com.h.pixeldroid.utils.ImageConverter
import com.h.pixeldroid.utils.Utils
import com.karumi.dexter.Dexter
import com.karumi.dexter.listener.PermissionDeniedResponse
import com.karumi.dexter.listener.PermissionGrantedResponse
import com.karumi.dexter.listener.single.BasePermissionListener
import kotlinx.android.synthetic.main.comment.view.*
import kotlinx.android.synthetic.main.post_fragment.view.*
import retrofit2.Call
import retrofit2.Callback
import retrofit2.Response
/**
* View Holder for a [Status] RecyclerView list item.
*/
class StatusViewHolder(val view: View) : RecyclerView.ViewHolder(view) {
val profilePic : ImageView = view.findViewById(R.id.profilePic)
val postPic : ImageView = view.findViewById(R.id.postPicture)
val username : TextView = view.findViewById(R.id.username)
val usernameDesc: TextView = view.findViewById(R.id.usernameDesc)
val description : TextView = view.findViewById(R.id.description)
val nlikes : TextView = view.findViewById(R.id.nlikes)
val nshares : TextView = view.findViewById(R.id.nshares)
//Spark buttons
val liker : SparkButton = view.findViewById(R.id.liker)
val reblogger : SparkButton = view.findViewById(R.id.reblogger)
val submitCmnt : ImageButton = view.findViewById(R.id.submitComment)
val commenter : ImageView = view.findViewById(R.id.commenter)
val comment : EditText = view.findViewById(R.id.editComment)
val commentCont : LinearLayout = view.findViewById(R.id.commentContainer)
val commentIn : LinearLayout = view.findViewById(R.id.commentIn)
val viewComment : TextView = view.findViewById(R.id.ViewComments)
val postDate : TextView = view.findViewById(R.id.postDate)
val postDomain : TextView = view.findViewById(R.id.postDomain)
val sensitiveW : TextView = view.findViewById(R.id.sensitiveWarning)
val postPager : ViewPager2 = view.findViewById(R.id.postPager)
val more : ImageButton = view.findViewById(R.id.status_more)
private var status: Status? = null
init {
itemView.setOnClickListener {
//notification?.openActivity()
}
}
fun bind(status: Status?, instanceUri: String, pixelfedAPI: PixelfedAPI, credential: String) {
this.status = status
val metrics = itemView.context.resources.displayMetrics
//Limit the height of the different images
postPic.maxHeight = metrics.heightPixels * 3/4
//Setup the post layout
val picRequest = Glide.with(itemView)
.asDrawable().fitCenter()
.placeholder(ColorDrawable(Color.GRAY))
setupPost(itemView, picRequest, instanceUri, false)
activateButtons(this, pixelfedAPI, credential)
}
fun setupPost(
rootView: View,
request: RequestBuilder<Drawable>,
//homeFragment: Fragment,
domain: String,
isActivity: Boolean
) {
//Setup username as a button that opens the profile
rootView.findViewById<TextView>(R.id.username).apply {
text = status?.account?.getDisplayName() ?: ""
setTypeface(null, Typeface.BOLD)
setOnClickListener { status?.account?.openProfile(rootView.context) }
}
rootView.findViewById<TextView>(R.id.usernameDesc).apply {
text = status?.account?.getDisplayName() ?: ""
setTypeface(null, Typeface.BOLD)
}
rootView.findViewById<TextView>(R.id.nlikes).apply {
text = status?.getNLikes(rootView.context)
setTypeface(null, Typeface.BOLD)
}
rootView.findViewById<TextView>(R.id.nshares).apply {
text = status?.getNShares(rootView.context)
setTypeface(null, Typeface.BOLD)
}
//Convert the date to a readable string
Utils.setTextViewFromISO8601(
status?.created_at!!,
rootView.postDate,
isActivity,
rootView.context
)
rootView.postDomain.text = status?.getStatusDomain(domain)
//Setup images
ImageConverter.setRoundImageFromURL(
rootView,
status?.getProfilePicUrl(),
rootView.profilePic
)
rootView.profilePic.setOnClickListener { status?.account?.openProfile(rootView.context) }
//Setup post pic only if there are media attachments
if(!status?.media_attachments.isNullOrEmpty()) {
setupPostPics(rootView, request)
} else {
rootView.postPicture.visibility = View.GONE
rootView.postPager.visibility = View.GONE
rootView.postTabs.visibility = View.GONE
}
//Set comment initial visibility
rootView.findViewById<LinearLayout>(R.id.commentIn).visibility = View.GONE
rootView.findViewById<LinearLayout>(R.id.commentContainer).visibility = View.GONE
}
fun setupPostPics(
rootView: View,
request: RequestBuilder<Drawable>,
//homeFragment: Fragment
) {
// Standard layout
rootView.postPicture.visibility = View.VISIBLE
rootView.postPager.visibility = View.GONE
rootView.postTabs.visibility = View.GONE
if(status?.media_attachments?.size == 1) {
request.load(status?.getPostUrl()).into(rootView.postPicture)
val imgDescription = status?.media_attachments?.get(0)?.description.orEmpty().ifEmpty { rootView.context.getString(
R.string.no_description) }
rootView.postPicture.contentDescription = imgDescription
rootView.postPicture.setOnLongClickListener {
Snackbar.make(it, imgDescription, Snackbar.LENGTH_SHORT).show()
true
}
} else if(status?.media_attachments?.size!! > 1) {
setupTabsLayout(rootView, request)
}
if (status?.sensitive!!) {
status?.setupSensitiveLayout(rootView)
}
}
fun setupTabsLayout(
rootView: View,
request: RequestBuilder<Drawable>,
) {
//Only show the viewPager and tabs
rootView.postPicture.visibility = View.GONE
rootView.postPager.visibility = View.VISIBLE
rootView.postTabs.visibility = View.VISIBLE
//Attach the given tabs to the view pager
rootView.postPager.adapter = AlbumViewPagerAdapter(status?.media_attachments ?: emptyList())
TabLayoutMediator(rootView.postTabs, rootView.postPager) { tab, _ ->
tab.icon = ContextCompat.getDrawable(rootView.context, R.drawable.ic_dot_blue_12dp)
}.attach()
}
fun setDescription(rootView: View, api: PixelfedAPI, credential: String) {
rootView.findViewById<TextView>(R.id.description).apply {
if (status?.content.isNullOrBlank()) {
visibility = View.GONE
} else {
text = HtmlUtils.parseHTMLText(status?.content.orEmpty(), status?.mentions, api, rootView.context, credential)
movementMethod = LinkMovementMethod.getInstance()
}
}
}
fun activateButtons(holder: StatusViewHolder, api: PixelfedAPI, credential: String){
//Set the special HTML text
setDescription(holder.view, api, credential)
//Activate onclickListeners
activateLiker(
holder, api, credential,
status?.favourited ?: false
)
activateReblogger(
holder, api, credential,
status?.reblogged ?: false
)
activateCommenter(holder, api, credential)
showComments(holder, api, credential)
//Activate double tap liking
activateDoubleTapLiker(holder, api, credential)
activateMoreButton(holder)
}
fun activateReblogger(
holder: StatusViewHolder,
api: PixelfedAPI,
credential: String,
isReblogged: Boolean
) {
holder.reblogger.apply {
//Set initial button state
isChecked = isReblogged
//Activate the button
setEventListener { _, buttonState ->
if (buttonState) {
// Button is active
undoReblogPost(holder, api, credential)
} else {
// Button is inactive
reblogPost(holder, api, credential)
}
//show animation or not?
true
}
}
}
fun reblogPost(
holder : StatusViewHolder,
api: PixelfedAPI,
credential: String
) {
//Call the api function
status?.id?.let {
api.reblogStatus(credential, it).enqueue(object : Callback<Status> {
override fun onFailure(call: Call<Status>, t: Throwable) {
Log.e("REBLOG ERROR", t.toString())
holder.reblogger.isChecked = false
}
override fun onResponse(call: Call<Status>, response: Response<Status>) {
if(response.code() == 200) {
val resp = response.body()!!
//Update shown share count
holder.nshares.text = resp.getNShares(holder.view.context)
holder.reblogger.isChecked = resp.reblogged!!
} else {
Log.e("RESPONSE_CODE", response.code().toString())
holder.reblogger.isChecked = false
}
}
})
}
}
fun undoReblogPost(
holder : StatusViewHolder,
api: PixelfedAPI,
credential: String,
) {
//Call the api function
status?.id?.let {
api.undoReblogStatus(credential, it).enqueue(object : Callback<Status> {
override fun onFailure(call: Call<Status>, t: Throwable) {
Log.e("REBLOG ERROR", t.toString())
holder.reblogger.isChecked = true
}
override fun onResponse(call: Call<Status>, response: Response<Status>) {
if(response.code() == 200) {
val resp = response.body()!!
//Update shown share count
holder.nshares.text = resp.getNShares(holder.view.context)
holder.reblogger.isChecked = resp.reblogged!!
} else {
Log.e("RESPONSE_CODE", response.code().toString())
holder.reblogger.isChecked = true
}
}
})
}
}
fun activateMoreButton(holder: StatusViewHolder){
holder.more.setOnClickListener {
PopupMenu(it.context, it).apply {
setOnMenuItemClickListener { item ->
when (item.itemId) {
R.id.post_more_menu_report -> {
val intent = Intent(it.context, ReportActivity::class.java)
intent.putExtra(Status.POST_TAG, status)
ContextCompat.startActivity(it.context, intent, null)
true
}
R.id.post_more_menu_share_link -> {
val share = Intent.createChooser(Intent().apply {
action = Intent.ACTION_SEND
putExtra(Intent.EXTRA_TEXT, status?.uri)
type = "text/plain"
putExtra(Intent.EXTRA_TITLE, status?.content)
}, null)
ContextCompat.startActivity(it.context, share, null)
true
}
R.id.post_more_menu_save_to_gallery -> {
Dexter.withContext(holder.view.context)
.withPermission(Manifest.permission.WRITE_EXTERNAL_STORAGE)
.withListener(object : BasePermissionListener() {
override fun onPermissionDenied(p0: PermissionDeniedResponse?) {
Toast.makeText(
holder.view.context,
holder.view.context.getString(R.string.write_permission_download_pic),
Toast.LENGTH_SHORT
).show()
}
override fun onPermissionGranted(p0: PermissionGrantedResponse?) {
status?.downloadImage(
holder.view.context,
status?.media_attachments?.get(holder.postPager.currentItem)?.url
?: "",
holder.view
)
}
}).check()
true
}
R.id.post_more_menu_share_picture -> {
Dexter.withContext(holder.view.context)
.withPermission(Manifest.permission.WRITE_EXTERNAL_STORAGE)
.withListener(object : BasePermissionListener() {
override fun onPermissionDenied(p0: PermissionDeniedResponse?) {
Toast.makeText(
holder.view.context,
holder.view.context.getString(R.string.write_permission_share_pic),
Toast.LENGTH_SHORT
).show()
}
override fun onPermissionGranted(p0: PermissionGrantedResponse?) {
status?.downloadImage(
holder.view.context,
status?.media_attachments?.get(holder.postPager.currentItem)?.url
?: "",
holder.view,
share = true,
)
}
}).check()
true
}
else -> false
}
}
inflate(R.menu.post_more_menu)
if(status?.media_attachments.isNullOrEmpty()) {
//make sure to disable image-related things if there aren't any
menu.setGroupVisible(R.id.post_more_group_picture, false)
}
show()
}
}
}
fun activateDoubleTapLiker(
holder: StatusViewHolder,
api: PixelfedAPI,
credential: String
) {
holder.apply {
var clicked = false
postPic.setOnClickListener {
//Check that the post isn't hidden
if(sensitiveW.visibility == View.GONE) {
//Check for double click
if(clicked) {
if (holder.liker.isChecked) {
// Button is active, unlike
holder.liker.isChecked = false
unLikePostCall(holder, api, credential)
} else {
// Button is inactive, like
holder.liker.playAnimation()
holder.liker.isChecked = true
likePostCall(holder, api, credential)
}
} else {
clicked = true
//Reset clicked to false after 500ms
postPic.handler.postDelayed(fun() { clicked = false }, 500)
}
}
}
}
}
fun activateLiker(
holder: StatusViewHolder,
api: PixelfedAPI,
credential: String,
isLiked: Boolean
) {
holder.liker.apply {
//Set initial state
isChecked = isLiked
//Activate the liker
setEventListener { _, buttonState ->
if (buttonState) {
// Button is active, unlike
unLikePostCall(holder, api, credential)
} else {
// Button is inactive, like
likePostCall(holder, api, credential)
}
//show animation or not?
true
}
}
}
fun likePostCall(
holder : StatusViewHolder,
api: PixelfedAPI,
credential: String,
) {
//Call the api function
status?.id?.let {
api.likePost(credential, it).enqueue(object : Callback<Status> {
override fun onFailure(call: Call<Status>, t: Throwable) {
Log.e("LIKE ERROR", t.toString())
holder.liker.isChecked = false
}
override fun onResponse(call: Call<Status>, response: Response<Status>) {
if(response.code() == 200) {
val resp = response.body()!!
//Update shown like count and internal like toggle
holder.nlikes.text = resp.getNLikes(holder.view.context)
holder.liker.isChecked = resp.favourited ?: false
} else {
Log.e("RESPONSE_CODE", response.code().toString())
holder.liker.isChecked = false
}
}
})
}
}
fun unLikePostCall(
holder : StatusViewHolder,
api: PixelfedAPI,
credential: String,
) {
//Call the api function
status?.id?.let {
api.unlikePost(credential, it).enqueue(object : Callback<Status> {
override fun onFailure(call: Call<Status>, t: Throwable) {
Log.e("UNLIKE ERROR", t.toString())
holder.liker.isChecked = true
}
override fun onResponse(call: Call<Status>, response: Response<Status>) {
if(response.code() == 200) {
val resp = response.body()!!
//Update shown like count and internal like toggle
holder.nlikes.text = resp.getNLikes(holder.view.context)
holder.liker.isChecked = resp.favourited ?: false
} else {
Log.e("RESPONSE_CODE", response.code().toString())
holder.liker.isChecked = true
}
}
})
}
}
fun showComments(
holder: StatusViewHolder,
api: PixelfedAPI,
credential: String
) {
//Show all comments of a post
if (status?.replies_count == 0) {
holder.viewComment.text = holder.view.context.getString(R.string.NoCommentsToShow)
} else {
holder.viewComment.apply {
text = "${status?.replies_count} ${holder.view.context.getString(R.string.CommentDisplay)}"
setOnClickListener {
visibility = View.GONE
//Retrieve the comments
retrieveComments(holder, api, credential)
}
}
}
}
fun activateCommenter(
holder: StatusViewHolder,
api: PixelfedAPI,
credential: String
) {
//Toggle comment button
toggleCommentInput(holder)
//Activate commenterpostPicture
holder.submitCmnt.setOnClickListener {
val textIn = holder.comment.text
//Open text input
if(textIn.isNullOrEmpty()) {
Toast.makeText(
holder.view.context,
holder.view.context.getString(R.string.empty_comment),
Toast.LENGTH_SHORT
).show()
} else {
//Post the comment
postComment(holder, api, credential)
}
}
}
fun toggleCommentInput(
holder : StatusViewHolder
) {
//Toggle comment button
holder.commenter.setOnClickListener {
when(holder.commentIn.visibility) {
View.VISIBLE -> {
holder.commentIn.visibility = View.GONE
ImageConverter.setImageFromDrawable(
holder.view,
holder.commenter,
R.drawable.ic_comment_empty
)
}
View.GONE -> {
holder.commentIn.visibility = View.VISIBLE
ImageConverter.setImageFromDrawable(
holder.view,
holder.commenter,
R.drawable.ic_comment_blue
)
}
}
}
}
fun addComment(context: android.content.Context, commentContainer: LinearLayout, commentUsername: String, commentContent: String) {
val view = LayoutInflater.from(context)
.inflate(R.layout.comment, commentContainer, true)
view.user.text = commentUsername
view.commentText.text = commentContent
}
fun retrieveComments(
holder : StatusViewHolder,
api: PixelfedAPI,
credential: String,
) {
status?.id?.let {
api.statusComments(it, credential).enqueue(object :
Callback<Context> {
override fun onFailure(call: Call<Context>, t: Throwable) {
Log.e("COMMENT FETCH ERROR", t.toString())
}
override fun onResponse(
call: Call<Context>,
response: Response<Context>
) {
if(response.code() == 200) {
val statuses = response.body()!!.descendants
holder.commentCont.removeAllViews()
//Create the new views for each comment
for (status in statuses) {
addComment(holder.view.context, holder.commentCont, status.account!!.username!!,
status.content!!
)
}
holder.commentCont.visibility = View.VISIBLE
} else {
Log.e("COMMENT ERROR", "${response.code()} with body ${response.errorBody()}")
}
}
})
}
}
fun postComment(
holder : StatusViewHolder,
api: PixelfedAPI,
credential: String,
) {
val textIn = holder.comment.text
val nonNullText = textIn.toString()
status?.id?.let {
api.postStatus(credential, nonNullText, it).enqueue(object :
Callback<Status> {
override fun onFailure(call: Call<Status>, t: Throwable) {
Log.e("COMMENT ERROR", t.toString())
Toast.makeText(
holder.view.context, holder.view.context.getString(R.string.comment_error),
Toast.LENGTH_SHORT
).show()
}
override fun onResponse(call: Call<Status>, response: Response<Status>) {
//Check that the received response code is valid
if (response.code() == 200) {
val resp = response.body()!!
holder.commentIn.visibility = View.GONE
//Add the comment to the comment section
addComment(
holder.view.context, holder.commentCont, resp.account!!.username!!,
resp.content!!
)
Toast.makeText(
holder.view.context,
holder.view.context.getString(R.string.comment_posted).format(textIn),
Toast.LENGTH_SHORT
).show()
Log.e("COMMENT SUCCESS", "posted: $textIn")
} else {
Log.e("ERROR_CODE", response.code().toString())
}
}
})
}
}
companion object {
fun create(parent: ViewGroup): StatusViewHolder {
val view = LayoutInflater.from(parent.context)
.inflate(R.layout.post_fragment, parent, false)
return StatusViewHolder(view)
}
}
}
class AlbumViewPagerAdapter(private val media_attachments: List<Attachment>) : RecyclerView.Adapter<AlbumViewPagerAdapter.ViewHolder>() {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
ViewHolder(LayoutInflater.from(parent.context).inflate(R.layout.album_image_view, parent, false))
override fun getItemCount() = media_attachments.size
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
Glide.with(holder.view)
.asDrawable().fitCenter().placeholder(ColorDrawable(Color.GRAY))
.load(media_attachments[position].url).into(holder.image)
val description = media_attachments[position].description
.orEmpty().ifEmpty{ holder.view.context.getString(R.string.no_description)}
holder.image.setOnLongClickListener {
Snackbar.make(it, description, Snackbar.LENGTH_SHORT).show()
true
}
holder.image.contentDescription = description
}
class ViewHolder(val view: View) : RecyclerView.ViewHolder(view){
val image: ImageView = view.findViewById(R.id.imageImageView)
}
}

View File

@ -1,209 +0,0 @@
package com.h.pixeldroid.fragments.feeds
import android.annotation.SuppressLint
import android.graphics.drawable.Drawable
import android.os.Bundle
import android.util.Log
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.ImageView
import android.widget.TextView
import androidx.lifecycle.LiveData
import androidx.recyclerview.widget.RecyclerView
import androidx.lifecycle.Observer
import androidx.paging.LivePagedListBuilder
import androidx.paging.PagedList
import com.bumptech.glide.Glide
import com.bumptech.glide.ListPreloader
import com.bumptech.glide.RequestBuilder
import com.bumptech.glide.integration.recyclerview.RecyclerViewPreloader
import com.bumptech.glide.request.RequestOptions
import com.bumptech.glide.util.ViewPreloadSizeProvider
import com.h.pixeldroid.R
import com.h.pixeldroid.objects.Account
import com.h.pixeldroid.objects.Account.Companion.ACCOUNT_ID_TAG
import com.h.pixeldroid.objects.Account.Companion.FOLLOWERS_TAG
import kotlinx.android.synthetic.main.account_list_entry.view.*
import retrofit2.Call
import retrofit2.Callback
import retrofit2.Response
open class AccountListFragment : FeedFragment() {
lateinit var profilePicRequest: RequestBuilder<Drawable>
protected lateinit var adapter : FeedsRecyclerViewAdapter<Account, AccountsRecyclerViewAdapter.ViewHolder>
lateinit var factory: FeedDataSourceFactory<String, Account>
lateinit var content: LiveData<PagedList<Account>>
private var currentPage = 1
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
val view = super.onCreateView(inflater, container, savedInstanceState)
//RequestBuilder that is re-used for every image
profilePicRequest = Glide.with(this)
.asDrawable().dontAnimate().apply(RequestOptions().circleCrop())
.placeholder(R.drawable.ic_default_user)
adapter = AccountsRecyclerViewAdapter()
list.adapter = adapter
//Make Glide be aware of the recyclerview and pre-load images
val sizeProvider: ListPreloader.PreloadSizeProvider<Account> = ViewPreloadSizeProvider()
val preloader: RecyclerViewPreloader<Account> = RecyclerViewPreloader(
Glide.with(this), adapter as AccountListFragment.AccountsRecyclerViewAdapter, sizeProvider, 4
)
list.addOnScrollListener(preloader)
return view
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
content = makeContent()
content.observe(viewLifecycleOwner,
Observer { c ->
adapter.submitList(c)
//after a refresh is done we need to stop the pull to refresh spinner
swipeRefreshLayout.isRefreshing = false
})
swipeRefreshLayout.setOnRefreshListener {
showError(show = false)
currentPage = 1
//by invalidating data, loadInitial will be called again
factory.liveData.value!!.invalidate()
}
}
internal open fun makeContent(): LiveData<PagedList<Account>> {
val id = arguments?.getSerializable(ACCOUNT_ID_TAG) as String
val following = arguments?.getSerializable(FOLLOWERS_TAG) as Boolean
val config: PagedList.Config = PagedList.Config.Builder().setPageSize(10).build()
val dataSource = AccountListDataSource(following, id)
factory = FeedDataSourceFactory(dataSource)
return LivePagedListBuilder(factory, config).build()
}
inner class AccountListDataSource(private val following: Boolean, private val id: String) :
FeedDataSource<String, Account>() {
override fun newSource(): AccountListDataSource {
return AccountListDataSource(following, id)
}
//We use the id as the key
override fun getKey(item: Account): String {
return currentPage.toString()
}
override fun makeInitialCall(requestedLoadSize: Int): Call<List<Account>> {
return if (following) {
pixelfedAPI.followers(
id, "Bearer $accessToken",
limit = requestedLoadSize
)
} else {
pixelfedAPI.following(
id, "Bearer $accessToken",
limit = requestedLoadSize
)
}
}
override fun makeAfterCall(requestedLoadSize: Int, key: String): Call<List<Account>> {
// Pixelfed and Mastodon don't implement this in the same fashion. Pixelfed uses
// Laravel's paging mechanism, while Mastodon uses the Link header for pagination.
// No need to know which is which, they should ignore the non-relevant argument
return if (following) {
pixelfedAPI.followers(
id, "Bearer $accessToken",
limit = requestedLoadSize, page = key, max_id = key
)
} else {
pixelfedAPI.following(
id, "Bearer $accessToken",
limit = requestedLoadSize, page = key, max_id = key
)
}
}
override fun enqueueCall(call: Call<List<Account>>, callback: LoadCallback<Account>){
call.enqueue(object : Callback<List<Account>> {
override fun onResponse(call: Call<List<Account>>, response: Response<List<Account>>) {
if (response.isSuccessful && response.body() != null) {
val data = response.body()!!
if(response.headers()["Link"] != null){
//Header is of the form:
// Link: <https://mastodon.social/api/v1/accounts/1/followers?limit=2&max_id=7628164>; rel="next", <https://mastodon.social/api/v1/accounts/1/followers?limit=2&since_id=7628165>; rel="prev"
// So we want the first max_id value. In case there are arguments after
// the max_id in the URL, we make sure to stop at the first '?'
currentPage = response.headers()["Link"]
.orEmpty()
.substringAfter("max_id=")
.substringBefore('?')
.substringBefore('>')
.toIntOrNull() ?: 0
} else {
currentPage++
}
callback.onResult(data)
} else{
showError()
}
swipeRefreshLayout.isRefreshing = false
loadingIndicator.visibility = View.GONE
}
override fun onFailure(call: Call<List<Account>>, t: Throwable) {
showError(errorText = R.string.feed_failed)
Log.e("AccountListFragment", t.toString())
}
})
}
}
inner class AccountsRecyclerViewAdapter : FeedsRecyclerViewAdapter<Account, AccountsRecyclerViewAdapter.ViewHolder>(),
ListPreloader.PreloadModelProvider<Account> {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
val view = LayoutInflater.from(parent.context)
.inflate(R.layout.account_list_entry, parent, false)
context = view.context
return ViewHolder(view)
}
override fun onBindViewHolder(holder : ViewHolder, position : Int) {
val account = getItem(position) ?: return
profilePicRequest.load(account.avatar).into(holder.avatar)
holder.username.text = account.username
@SuppressLint("SetTextI18n")
holder.acct.text = "@${account.acct}"
holder.mView.setOnClickListener { account.openProfile(context) }
}
inner class ViewHolder(val mView : View) : RecyclerView.ViewHolder(mView) {
val avatar : ImageView = mView.account_entry_avatar
val username : TextView = mView.account_entry_username
val acct: TextView = mView.account_entry_acct
}
override fun getPreloadItems(position : Int) : MutableList<Account> {
val account = getItem(position) ?: return mutableListOf()
return mutableListOf(account)
}
override fun getPreloadRequestBuilder(item : Account) : RequestBuilder<*>? {
return profilePicRequest.load(item.avatar_static)
}
}
}

View File

@ -0,0 +1,123 @@
package com.h.pixeldroid.fragments.feeds
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.ProgressBar
import androidx.annotation.StringRes
import androidx.constraintlayout.motion.widget.MotionLayout
import androidx.core.view.isVisible
import androidx.core.view.size
import androidx.paging.LoadState
import androidx.paging.LoadStateAdapter
import androidx.paging.PagingDataAdapter
import androidx.recyclerview.widget.RecyclerView
import com.h.pixeldroid.R
import com.h.pixeldroid.databinding.FragmentFeedBinding
import com.h.pixeldroid.databinding.LoadStateFooterViewItemBinding
/**
* Shows or hides the error in the different FeedFragments
*/
private fun showError(errorText: String, show: Boolean = true, binding: FragmentFeedBinding){
if(show){
binding.motionLayout.transitionToEnd()
binding.errorLayout.errorText.text = errorText
} else if(binding.motionLayout.progress == 1F){
binding.motionLayout.transitionToStart()
}
}
/**
* Initialises the [RecyclerView] adapter for the different FeedFragments.
*
* Makes the UI respond to various [LoadState]s, including errors when an error message is shown.
*/
internal fun <T: Any> initAdapter(binding: FragmentFeedBinding, adapter: PagingDataAdapter<T, RecyclerView.ViewHolder>) {
binding.list.adapter = adapter.withLoadStateFooter(
footer = ReposLoadStateAdapter { adapter.retry() }
)
adapter.addLoadStateListener { loadState ->
if(!binding.progressBar.isVisible && binding.swipeRefreshLayout.isRefreshing) {
// Stop loading spinner when loading is done
binding.swipeRefreshLayout.isRefreshing = loadState.refresh is LoadState.Loading
} else {
// ProgressBar should stop showing as soon as the source stops loading ("source"
// meaning the database, so don't wait on the network)
val sourceLoading = loadState.source.refresh is LoadState.Loading
if(!sourceLoading && binding.list.size > 0){
binding.list.isVisible = true
binding.progressBar.isVisible = false
} else if(binding.list.size == 0
&& loadState.append is LoadState.NotLoading
&& loadState.append.endOfPaginationReached){
binding.progressBar.isVisible = false
showError(binding = binding, errorText = "Nothing to see here :(")
}
}
// Toast on any error, regardless of whether it came from RemoteMediator or PagingSource
val errorState = loadState.source.append as? LoadState.Error
?: loadState.source.prepend as? LoadState.Error
?: loadState.source.refresh as? LoadState.Error
?: loadState.append as? LoadState.Error
?: loadState.prepend as? LoadState.Error
?: loadState.refresh as? LoadState.Error
errorState?.let {
showError(binding = binding, errorText = it.error.toString())
}
if (errorState == null) showError(binding = binding, show = false, errorText = "")
}
}
/**
* Adapter to the show the a [RecyclerView] item for a [LoadState], with a callback to retry if
* the retry button is pressed.
*/
class ReposLoadStateAdapter(
private val retry: () -> Unit
) : LoadStateAdapter<ReposLoadStateViewHolder>() {
override fun onBindViewHolder(holder: ReposLoadStateViewHolder, loadState: LoadState) {
holder.bind(loadState)
}
override fun onCreateViewHolder(parent: ViewGroup, loadState: LoadState): ReposLoadStateViewHolder {
return ReposLoadStateViewHolder.create(parent, retry)
}
}
/**
* [RecyclerView.ViewHolder] that is shown at the end of the feed to indicate loading or errors
* in the loading of appending values.
*/
class ReposLoadStateViewHolder(
private val binding: LoadStateFooterViewItemBinding,
retry: () -> Unit
) : RecyclerView.ViewHolder(binding.root) {
init {
binding.retryButton.setOnClickListener { retry.invoke() }
}
fun bind(loadState: LoadState) {
if (loadState is LoadState.Error) {
binding.errorMsg.text = loadState.error.localizedMessage
}
binding.progressBar.isVisible = loadState is LoadState.Loading
binding.retryButton.isVisible = loadState !is LoadState.Loading
binding.errorMsg.isVisible = loadState !is LoadState.Loading
}
companion object {
fun create(parent: ViewGroup, retry: () -> Unit): ReposLoadStateViewHolder {
val view = LayoutInflater.from(parent.context)
.inflate(R.layout.load_state_footer_view_item, parent, false)
val binding = LoadStateFooterViewItemBinding.bind(view)
return ReposLoadStateViewHolder(binding, retry)
}
}
}

View File

@ -1,165 +0,0 @@
package com.h.pixeldroid.fragments.feeds
import android.content.Context
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.View.GONE
import android.view.View.VISIBLE
import android.view.ViewGroup
import android.widget.ProgressBar
import android.widget.TextView
import androidx.annotation.StringRes
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.fragment.app.Fragment
import androidx.lifecycle.MutableLiveData
import androidx.paging.DataSource
import androidx.paging.ItemKeyedDataSource
import androidx.paging.PagedListAdapter
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
import com.h.pixeldroid.Pixeldroid
import com.h.pixeldroid.R
import com.h.pixeldroid.api.PixelfedAPI
import com.h.pixeldroid.db.AppDatabase
import com.h.pixeldroid.db.UserDatabaseEntity
import com.h.pixeldroid.di.PixelfedAPIHolder
import com.h.pixeldroid.objects.FeedContent
import kotlinx.android.synthetic.main.fragment_feed.*
import kotlinx.android.synthetic.main.fragment_feed.view.*
import org.w3c.dom.Text
import retrofit2.Call
import javax.inject.Inject
open class FeedFragment: Fragment() {
protected var accessToken: String? = null
@Inject
lateinit var apiHolder: PixelfedAPIHolder
protected lateinit var pixelfedAPI: PixelfedAPI
protected lateinit var list : RecyclerView
protected lateinit var swipeRefreshLayout: SwipeRefreshLayout
internal lateinit var loadingIndicator: ProgressBar
var user: UserDatabaseEntity? = null
@Inject
lateinit var db: AppDatabase
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
val view = inflater.inflate(R.layout.fragment_feed, container, false)
(requireActivity().application as Pixeldroid).getAppComponent().inject(this)
//Initialize lateinit fields that are needed as soon as the view is created
swipeRefreshLayout = view.findViewById(R.id.swipeRefreshLayout)
loadingIndicator = view.findViewById(R.id.progressBar)
list = swipeRefreshLayout.list
list.layoutManager = LinearLayoutManager(context)
user = db.userDao().getActiveUser()
pixelfedAPI = apiHolder.api ?: apiHolder.setDomainToCurrentUser(db)
accessToken = user?.accessToken.orEmpty()
return view
}
fun showError(@StringRes errorText: Int = R.string.loading_toast, show: Boolean = true){
val errorLayout = view?.findViewById<ConstraintLayout>(R.id.errorLayout)
val progressBar = view?.findViewById<ProgressBar>(R.id.progressBar)
if(show){
view?.findViewById<TextView>(R.id.error_text)?.setText(errorText)
errorLayout?.visibility = VISIBLE
progressBar?.visibility = GONE
} else {
errorLayout?.visibility = GONE
progressBar?.visibility = VISIBLE
}
}
open inner class FeedDataSourceFactory<ObjectId, APIObject: FeedContent>(
private val dataSource: FeedDataSource<ObjectId, APIObject>
): DataSource.Factory<ObjectId, APIObject>() {
internal lateinit var liveData: MutableLiveData<FeedDataSource<ObjectId, APIObject>>
override fun create(): DataSource<ObjectId, APIObject> {
val dataSource = dataSource.newSource()
liveData = MutableLiveData()
liveData.postValue(dataSource)
return dataSource
}
}
abstract inner class FeedDataSource<ObjectId, APIObject: FeedContent>: ItemKeyedDataSource<ObjectId, APIObject>(){
/**
* Used in the initial call to initialize the list [loadInitial].
* @param requestedLoadSize number of objects requested in a call
* @return [Call] that gets the list of [APIObject]
*/
abstract fun makeInitialCall(requestedLoadSize: Int): Call<List<APIObject>>
/**
* Used in the subsequent calls to get more objects.
* @param requestedLoadSize number of objects requested in a call
* @param key of the last object we already have
* @return [Call] that gets the list of [APIObject]
*/
abstract fun makeAfterCall(requestedLoadSize: Int, key: ObjectId): Call<List<APIObject>>
/**
* This is called to initialize the list, so we want some of the most recent objects.
* @param params holds the requestedLoadSize
* @param callback to call after network request completes
*/
override fun loadInitial(
params: LoadInitialParams<ObjectId>,
callback: LoadInitialCallback<APIObject>
) {
enqueueCall(makeInitialCall(params.requestedLoadSize), callback)
}
/**
* This is called to when we get to the bottom of the loaded content, so we want objects
* older than the given key (params.key).
* @param params holds the requestedLoadSize
* @param callback to call after network request completes
*/
override fun loadAfter(params: LoadParams<ObjectId>, callback: LoadCallback<APIObject>) {
enqueueCall(makeAfterCall(params.requestedLoadSize, params.key), callback)
}
/**
* Do nothing here, it is expected to pull to refresh to load newer items
*/
override fun loadBefore(params: LoadParams<ObjectId>, callback: LoadCallback<APIObject>) {}
abstract fun enqueueCall(call: Call<List<APIObject>>, callback: LoadCallback<APIObject>)
abstract fun newSource(): FeedDataSource<ObjectId, APIObject>
}
}
abstract class FeedsRecyclerViewAdapter<T: FeedContent, VH : RecyclerView.ViewHolder?>: PagedListAdapter<T, VH>(
object : DiffUtil.ItemCallback<T>() {
override fun areItemsTheSame(oldItem: T, newItem: T): Boolean {
return oldItem.id === newItem.id
}
override fun areContentsTheSame(oldItem: T, newItem: T): Boolean {
return oldItem.equals(newItem)
}
}
){
protected lateinit var context: Context
}

View File

@ -1,269 +0,0 @@
package com.h.pixeldroid.fragments.feeds
import android.content.Context
import android.content.Intent
import android.graphics.drawable.Drawable
import android.os.Bundle
import android.util.Log
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.ImageView
import android.widget.TextView
import androidx.lifecycle.LiveData
import androidx.lifecycle.Observer
import androidx.paging.LivePagedListBuilder
import androidx.paging.PagedList
import androidx.recyclerview.widget.RecyclerView
import com.bumptech.glide.Glide
import com.bumptech.glide.ListPreloader
import com.bumptech.glide.RequestBuilder
import com.bumptech.glide.integration.recyclerview.RecyclerViewPreloader
import com.bumptech.glide.request.RequestOptions
import com.bumptech.glide.util.ViewPreloadSizeProvider
import com.h.pixeldroid.PostActivity
import com.h.pixeldroid.ProfileActivity
import com.h.pixeldroid.R
import com.h.pixeldroid.objects.Account
import com.h.pixeldroid.objects.Notification
import com.h.pixeldroid.objects.Status
import com.h.pixeldroid.utils.HtmlUtils.Companion.parseHTMLText
import com.h.pixeldroid.utils.Utils.Companion.setTextViewFromISO8601
import kotlinx.android.synthetic.main.fragment_notifications.view.*
import retrofit2.Call
import retrofit2.Callback
import retrofit2.Response
/**
* A fragment representing a list of Items.
*/
class NotificationsFragment : FeedFragment() {
lateinit var profilePicRequest: RequestBuilder<Drawable>
protected lateinit var adapter : FeedsRecyclerViewAdapter<Notification, NotificationsRecyclerViewAdapter.ViewHolder>
lateinit var factory: FeedDataSourceFactory<String, Notification>
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
val view = super.onCreateView(inflater, container, savedInstanceState)
//RequestBuilder that is re-used for every image
profilePicRequest = Glide.with(this)
.asDrawable().apply(RequestOptions().circleCrop())
.placeholder(R.drawable.ic_default_user)
adapter = NotificationsRecyclerViewAdapter()
list.adapter = adapter
//Make Glide be aware of the recyclerview and pre-load images
val sizeProvider: ListPreloader.PreloadSizeProvider<Notification> = ViewPreloadSizeProvider()
val preloader: RecyclerViewPreloader<Notification> = RecyclerViewPreloader(
Glide.with(this), adapter as NotificationsFragment.NotificationsRecyclerViewAdapter, sizeProvider, 4
)
list.addOnScrollListener(preloader)
return view
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
val content = makeContent()
content.observe(viewLifecycleOwner,
Observer { c ->
adapter.submitList(c)
//after a refresh is done we need to stop the pull to refresh spinner
swipeRefreshLayout.isRefreshing = false
})
swipeRefreshLayout.setOnRefreshListener {
showError(show = false)
//by invalidating data, loadInitial will be called again
factory.liveData.value!!.invalidate()
}
}
private fun makeContent(): LiveData<PagedList<Notification>> {
val config: PagedList.Config = PagedList.Config.Builder().setPageSize(10).build()
val dataSource = NotificationListDataSource()
factory = FeedDataSourceFactory(dataSource)
return LivePagedListBuilder(factory, config).build()
}
inner class NotificationListDataSource: FeedDataSource<String, Notification>() {
override fun newSource(): NotificationListDataSource {
return NotificationListDataSource()
}
//We use the id as the key
override fun getKey(item: Notification): String {
return item.id
}
override fun makeInitialCall(requestedLoadSize: Int): Call<List<Notification>> {
return pixelfedAPI
.notifications("Bearer $accessToken", limit="$requestedLoadSize")
}
override fun makeAfterCall(requestedLoadSize: Int, key: String): Call<List<Notification>> {
return pixelfedAPI
.notifications("Bearer $accessToken", max_id=key, limit="$requestedLoadSize")
}
override fun enqueueCall(call: Call<List<Notification>>, callback: LoadCallback<Notification>){
call.enqueue(object : Callback<List<Notification>> {
override fun onResponse(call: Call<List<Notification>>, response: Response<List<Notification>>) {
if (response.isSuccessful && response.body() != null) {
val data = response.body()!!
callback.onResult(data)
} else {
showError()
}
swipeRefreshLayout.isRefreshing = false
loadingIndicator.visibility = View.GONE
}
override fun onFailure(call: Call<List<Notification>>, t: Throwable) {
showError(errorText = R.string.feed_failed)
Log.e("NotificationsFragment", t.toString())
}
})
}
}
/**
* [RecyclerView.Adapter] that can display a [Notification]
*/
inner class NotificationsRecyclerViewAdapter: FeedsRecyclerViewAdapter<Notification, NotificationsRecyclerViewAdapter.ViewHolder>(),
ListPreloader.PreloadModelProvider<Notification> {
private val mOnClickListener: View.OnClickListener
init {
mOnClickListener = View.OnClickListener { v ->
val notification = v.tag as Notification
openActivity(notification)
}
}
private fun openPostFromNotification(notification: Notification) : Intent {
val intent = Intent(context, PostActivity::class.java)
intent.putExtra(Status.POST_TAG, notification.status)
return intent
}
private fun openActivity(notification: Notification){
val intent: Intent
when (notification.type){
Notification.NotificationType.mention, Notification.NotificationType.favourite,
Notification.NotificationType.poll, Notification.NotificationType.reblog -> {
intent = openPostFromNotification(notification)
}
Notification.NotificationType.follow -> {
intent = Intent(context, ProfileActivity::class.java)
intent.putExtra(Account.ACCOUNT_TAG, notification.account)
}
}
context.startActivity(intent)
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
val view = LayoutInflater.from(parent.context)
.inflate(R.layout.fragment_notifications, parent, false)
context = view.context
return ViewHolder(view)
}
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
val notification = getItem(position) ?: return
profilePicRequest.load(notification.account.avatar_static).into(holder.avatar)
val previewUrl = notification.status?.media_attachments?.getOrNull(0)?.preview_url
if(!previewUrl.isNullOrBlank()){
Glide.with(holder.mView).load(previewUrl)
.placeholder(R.drawable.ic_picture_fallback).into(holder.photoThumbnail)
} else{
holder.photoThumbnail.visibility = View.GONE
}
setNotificationType(notification.type, notification.account.username!!, holder.notificationType)
setTextViewFromISO8601(notification.created_at, holder.notificationTime, false, context)
//Convert HTML to clickable text
holder.postDescription.text =
parseHTMLText(
notification.status?.content ?: "",
notification.status?.mentions,
pixelfedAPI,
context,
"Bearer $accessToken"
)
with(holder.mView) {
tag = notification
setOnClickListener(mOnClickListener)
}
}
private fun setNotificationType(type: Notification.NotificationType, username: String,
textView: TextView
){
val context = textView.context
val (format: String, drawable: Drawable?) = when(type) {
Notification.NotificationType.follow -> {
setNotificationTypeTextView(context, R.string.followed_notification, R.drawable.ic_follow)
}
Notification.NotificationType.mention -> {
setNotificationTypeTextView(context, R.string.mention_notification, R.drawable.ic_apenstaart)
}
Notification.NotificationType.reblog -> {
setNotificationTypeTextView(context, R.string.shared_notification, R.drawable.ic_reblog_blue)
}
Notification.NotificationType.favourite -> {
setNotificationTypeTextView(context, R.string.liked_notification, R.drawable.ic_like_full)
}
Notification.NotificationType.poll -> {
setNotificationTypeTextView(context, R.string.poll_notification, R.drawable.poll)
}
}
textView.text = format.format(username)
textView.setCompoundDrawablesWithIntrinsicBounds(
drawable,null,null,null
)
}
private fun setNotificationTypeTextView(context: Context, format: Int, drawable: Int): Pair<String, Drawable?> {
return Pair(context.getString(format), context.getDrawable(drawable))
}
inner class ViewHolder(val mView: View) : RecyclerView.ViewHolder(mView) {
val notificationType: TextView = mView.notification_type
val notificationTime: TextView = mView.notification_time
val postDescription: TextView = mView.notification_post_description
val avatar: ImageView = mView.notification_avatar
val photoThumbnail: ImageView = mView.notification_photo_thumbnail
}
override fun getPreloadItems(position: Int): MutableList<Notification> {
val notification = getItem(position) ?: return mutableListOf()
return mutableListOf(notification)
}
override fun getPreloadRequestBuilder(item: Notification): RequestBuilder<*>? {
return profilePicRequest.load(item.account.avatar_static)
}
}
}

View File

@ -1,211 +0,0 @@
package com.h.pixeldroid.fragments.feeds
import android.content.Intent
import android.graphics.Color
import android.graphics.Typeface
import android.graphics.drawable.ColorDrawable
import android.graphics.drawable.Drawable
import android.os.Bundle
import androidx.fragment.app.Fragment
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.EditText
import android.widget.ImageView
import android.widget.TextView
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import androidx.viewpager2.adapter.FragmentStateAdapter
import com.bumptech.glide.Glide
import com.bumptech.glide.RequestBuilder
import com.google.android.material.tabs.TabLayoutMediator
import com.h.pixeldroid.MainActivity
import com.h.pixeldroid.Pixeldroid
import com.h.pixeldroid.R
import com.h.pixeldroid.db.AppDatabase
import com.h.pixeldroid.db.PostDatabaseEntity
import com.h.pixeldroid.fragments.ImageFragment
import com.h.pixeldroid.utils.*
import com.h.pixeldroid.utils.Utils.Companion.setTextViewFromISO8601
import kotlinx.android.synthetic.main.fragment_offline_feed.view.*
import kotlinx.android.synthetic.main.post_fragment.view.*
import javax.inject.Inject
class OfflineFeedFragment: Fragment() {
private lateinit var recyclerView: RecyclerView
private lateinit var viewAdapter: RecyclerView.Adapter<*>
private lateinit var viewManager: RecyclerView.LayoutManager
lateinit var picRequest: RequestBuilder<Drawable>
@Inject
lateinit var db: AppDatabase
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
arguments?.let {
}
}
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
// Inflate the layout for this fragment
val view = inflater.inflate(R.layout.fragment_offline_feed, container, false)
val loadingAnimation = view.offline_feed_progress_bar
loadingAnimation.visibility = View.VISIBLE
picRequest = Glide.with(this)
.asDrawable().fitCenter()
.placeholder(ColorDrawable(Color.GRAY))
(requireActivity().application as Pixeldroid).getAppComponent().inject(this)
val user = db.userDao().getActiveUser()!!
if (db.postDao().numberOfPosts(user.user_id, user.instance_uri) > 0) {
val posts = db.postDao().getAll(user.user_id, user.instance_uri)
viewManager = LinearLayoutManager(requireContext())
viewAdapter = OfflinePostFeedAdapter(posts)
loadingAnimation.visibility = View.GONE
recyclerView = view.offline_feed_recyclerview.apply {
visibility = View.VISIBLE
// use this setting to improve performance if you know that changes
// in content do not change the layout size of the RecyclerView
setHasFixedSize(true)
// use a linear layout manager
layoutManager = viewManager
// specify an viewAdapter (see also next example)
adapter = viewAdapter
}
} else {
loadingAnimation.visibility = View.GONE
view.offline_feed_placeholder_text.visibility = View.VISIBLE
}
view.offline_feed_progress_bar.visibility = View.GONE
return view
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
view.swipeRefreshLayout.setOnRefreshListener {
if (Utils.hasInternet(requireContext())) {
onStop()
startActivity(Intent(requireContext(), MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
})
}
view.swipeRefreshLayout.isRefreshing = false
}
}
inner class OfflinePostFeedAdapter(private val posts: List<PostDatabaseEntity>)
: RecyclerView.Adapter<OfflinePostFeedAdapter.OfflinePostViewHolder>() {
inner class OfflinePostViewHolder(private val postView: View)
: RecyclerView.ViewHolder(postView) {
val profilePic : ImageView = postView.findViewById(R.id.profilePic)
val postPic : ImageView = postView.findViewById(R.id.postPicture)
val username : TextView = postView.findViewById(R.id.username)
val description : TextView = postView.findViewById(R.id.description)
val comment : EditText = postView.findViewById(R.id.editComment)
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): OfflinePostViewHolder {
val view = LayoutInflater.from(parent.context)
.inflate(R.layout.post_fragment, parent, false)
.apply {
commenter.visibility = View.GONE
postDomain.visibility = View.GONE
commentIn.visibility = View.GONE
liker.apply {
//de-activate the liker
setEventListener { _, _ ->
false
}
}
reblogger.apply {
//de-activate the reblogger
setEventListener { _, _ ->
false
}
}
}
return OfflinePostViewHolder(view)
}
override fun onBindViewHolder(holder: OfflinePostViewHolder, position: Int) {
val post = posts[position]
val metrics = requireContext().resources.displayMetrics
//Limit the height of the different images
holder.profilePic.maxHeight = metrics.heightPixels
holder.postPic.maxHeight = metrics.heightPixels
//Setup username as a button that opens the profile
holder.itemView.username.apply {
text = post.account_name
setTypeface(null, Typeface.BOLD)
}
//Convert the date to a readable string
setTextViewFromISO8601(post.date, holder.itemView.postDate, false, requireContext())
//Setup images
ImageConverter.setRoundImageFromURL(
holder.itemView,
post.account_profile_picture,
holder.profilePic
)
//Setup post pic only if there are media attachments
if(!post.media_urls.isNullOrEmpty()) {
// Standard layout
holder.postPic.visibility = View.VISIBLE
holder.itemView.postPager.visibility = View.GONE
holder.itemView.postTabs.visibility = View.GONE
holder.itemView.sensitiveWarning.visibility = View.GONE
if(post.media_urls.size == 1) {
picRequest.load(post.media_urls[0]).into(holder.postPic)
} else {
//Only show the viewPager and tabs
holder.postPic.visibility = View.GONE
holder.itemView.postPager.visibility = View.VISIBLE
holder.itemView.postTabs.visibility = View.VISIBLE
val tabs : ArrayList<ImageFragment> = ArrayList()
//Fill the tabs with each mediaAttachment
for((index, media) in post.media_urls.withIndex()) {
tabs.add(ImageFragment.newInstance(media, "Photo $index"))
}
holder.itemView.postPager.adapter = object : FragmentStateAdapter(this@OfflineFeedFragment) {
override fun createFragment(position: Int): Fragment {
return tabs[position]
}
override fun getItemCount(): Int {
return post.media_urls.size
}
}
TabLayoutMediator(holder.itemView.postTabs, holder.itemView.postPager) { tab, _ ->
tab.icon = holder.itemView.context.getDrawable(R.drawable.ic_dot_blue_12dp)
}.attach()
}
}
holder.description.apply {
if(post.description.isBlank()) {
visibility = View.GONE
} else {
text = HtmlUtils.fromHtml(post.description)
}
}
holder.itemView.nlikes.text = post.likes.toString()
holder.itemView.nshares.text = post.shares.toString()
}
override fun getItemCount(): Int {
return posts.size
}
}
}

View File

@ -0,0 +1,107 @@
package com.h.pixeldroid.fragments.feeds.cachedFeeds
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.lifecycleScope
import androidx.paging.*
import androidx.recyclerview.widget.RecyclerView
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.distinctUntilChangedBy
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.launch
import com.h.pixeldroid.databinding.FragmentFeedBinding
import com.h.pixeldroid.db.AppDatabase
import com.h.pixeldroid.db.dao.feedContent.FeedContentDao
import com.h.pixeldroid.fragments.BaseFragment
import com.h.pixeldroid.fragments.feeds.initAdapter
import com.h.pixeldroid.objects.FeedContentDatabase
/**
* A fragment representing a list of [FeedContentDatabase] items that are cached by the database.
*/
open class CachedFeedFragment<T: FeedContentDatabase> : BaseFragment() {
internal lateinit var viewModel: FeedViewModel<T>
internal lateinit var adapter: PagingDataAdapter<T, RecyclerView.ViewHolder>
private lateinit var binding: FragmentFeedBinding
private var job: Job? = null
internal fun launch() {
// Make sure we cancel the previous job before creating a new one
job?.cancel()
job = lifecycleScope.launch {
viewModel.flow().collectLatest {
adapter.submitData(it)
}
}
}
internal fun initSearch() {
// Scroll to top when the list is refreshed from network.
lifecycleScope.launch {
adapter.loadStateFlow
// Only emit when REFRESH LoadState for RemoteMediator changes.
.distinctUntilChangedBy { it.refresh }
// Only react to cases where Remote REFRESH completes i.e., NotLoading.
.filter { it.refresh is LoadState.NotLoading }
.collect { binding.list.scrollToPosition(0) }
}
}
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
super.onCreateView(inflater, container, savedInstanceState)
binding = FragmentFeedBinding.inflate(layoutInflater)
initAdapter(binding, adapter)
//binding.progressBar.visibility = View.GONE
binding.swipeRefreshLayout.setOnRefreshListener {
//It shouldn't be necessary to also retry() in addition to refresh(),
//but if we don't do this, reloads after an error fail immediately...
// https://issuetracker.google.com/issues/173438474
adapter.retry()
adapter.refresh()
}
return binding.root
}
}
/**
* Factory that creates ViewModel from a [FeedContentRepository], to be used in cached feeds to
* fetch the ViewModel that is responsible for preparing and managing the data for
* an Activity or a Fragment
*/
class ViewModelFactory<U: FeedContentDatabase> @ExperimentalPagingApi constructor(private val db: AppDatabase?,
private val dao: FeedContentDao<U>?,
private val remoteMediator: RemoteMediator<Int, U>?,
private val feedContentRepository: FeedContentRepository<U> = FeedContentRepository(db!!, dao!!, remoteMediator!!)
) : ViewModelProvider.Factory {
@ExperimentalPagingApi
override fun <T : ViewModel> create(modelClass: Class<T>): T {
if (modelClass.isAssignableFrom(FeedViewModel::class.java)) {
@Suppress("UNCHECKED_CAST")
return FeedViewModel(feedContentRepository) as T
}
throw IllegalArgumentException("Unknown ViewModel class")
}
}

View File

@ -0,0 +1,61 @@
/*
* Copyright (C) 2018 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.h.pixeldroid.fragments.feeds.cachedFeeds
import androidx.paging.*
import com.h.pixeldroid.db.AppDatabase
import com.h.pixeldroid.db.dao.feedContent.FeedContentDao
import com.h.pixeldroid.objects.FeedContentDatabase
import kotlinx.coroutines.flow.Flow
import javax.inject.Inject
/**
* Repository class that works with local and remote data sources.
*/
class FeedContentRepository<T: FeedContentDatabase> @ExperimentalPagingApi
@Inject constructor(
private val db: AppDatabase,
private val dao: FeedContentDao<T>,
private val mediator: RemoteMediator<Int, T>
) {
/**
* [FeedContentDatabase], exposed as a stream of data that will emit
* every time we get more data from the network.
*/
fun stream(): Flow<PagingData<T>> {
val pagingSourceFactory = {
val user = db.userDao().getActiveUser()!!
dao.feedContent(user.user_id, user.instance_uri)
}
return Pager(
config = PagingConfig(initialLoadSize = NETWORK_PAGE_SIZE,
pageSize = NETWORK_PAGE_SIZE,
enablePlaceholders = false,
prefetchDistance = 50
),
remoteMediator = mediator,
pagingSourceFactory = pagingSourceFactory
).flow
}
companion object {
private const val NETWORK_PAGE_SIZE = 20
}
}

View File

@ -0,0 +1,43 @@
/*
* Copyright (C) 2018 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.h.pixeldroid.fragments.feeds.cachedFeeds
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.paging.*
import com.h.pixeldroid.objects.FeedContentDatabase
import kotlinx.coroutines.flow.Flow
/**
* ViewModel for the cached feeds.
* The ViewModel works with the [FeedContentRepository] to get the data.
*/
class FeedViewModel<T: FeedContentDatabase>(private val repository: FeedContentRepository<T>) : ViewModel() {
private var currentResult: Flow<PagingData<T>>? = null
fun flow(): Flow<PagingData<T>> {
val lastResult = currentResult
if (lastResult != null) {
return lastResult
}
val newResult: Flow<PagingData<T>> = repository.stream()
.cachedIn(viewModelScope)
currentResult = newResult
return newResult
}
}

View File

@ -0,0 +1,208 @@
package com.h.pixeldroid.fragments.feeds.cachedFeeds.notifications
import android.content.Context
import android.content.Intent
import android.graphics.drawable.Drawable
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.ImageView
import android.widget.TextView
import androidx.core.content.ContextCompat
import androidx.lifecycle.ViewModelProvider
import androidx.paging.ExperimentalPagingApi
import androidx.paging.PagingDataAdapter
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.RecyclerView
import com.bumptech.glide.Glide
import com.h.pixeldroid.PostActivity
import com.h.pixeldroid.ProfileActivity
import com.h.pixeldroid.R
import com.h.pixeldroid.api.PixelfedAPI
import com.h.pixeldroid.db.AppDatabase
import com.h.pixeldroid.di.PixelfedAPIHolder
import com.h.pixeldroid.objects.Account
import com.h.pixeldroid.objects.Notification
import com.h.pixeldroid.objects.Status
import com.h.pixeldroid.utils.Utils.Companion.setTextViewFromISO8601
import kotlinx.android.synthetic.main.fragment_notifications.view.*
import com.h.pixeldroid.fragments.feeds.cachedFeeds.CachedFeedFragment
import com.h.pixeldroid.fragments.feeds.cachedFeeds.FeedViewModel
import com.h.pixeldroid.fragments.feeds.cachedFeeds.ViewModelFactory
import com.h.pixeldroid.utils.HtmlUtils.Companion.parseHTMLText
/**
* Fragment for the notifications tab.
*/
class NotificationsFragment : CachedFeedFragment<Notification>() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
adapter = NotificationsAdapter(apiHolder, db)
}
@ExperimentalPagingApi
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
val view = super.onCreateView(inflater, container, savedInstanceState)
// get the view model
@Suppress("UNCHECKED_CAST")
viewModel = ViewModelProvider(this, ViewModelFactory(db, db.notificationDao(), NotificationsRemoteMediator(apiHolder, db)))
.get(FeedViewModel::class.java) as FeedViewModel<Notification>
launch()
initSearch()
return view
}
}
/**
* View Holder for a [Notification] RecyclerView list item.
*/
class NotificationViewHolder(view: View) : RecyclerView.ViewHolder(view) {
private val notificationType: TextView = view.notification_type
private val notificationTime: TextView = view.notification_time
private val postDescription: TextView = view.notification_post_description
private val avatar: ImageView = view.notification_avatar
private val photoThumbnail: ImageView = view.notification_photo_thumbnail
private var notification: Notification? = null
init {
itemView.setOnClickListener {
notification?.openActivity()
}
}
private fun Notification.openActivity() {
val intent: Intent
when (type){
Notification.NotificationType.mention, Notification.NotificationType.favourite,
Notification.NotificationType.poll, Notification.NotificationType.reblog -> {
intent = openPostFromNotification()
}
Notification.NotificationType.follow -> {
intent = Intent(itemView.context, ProfileActivity::class.java)
intent.putExtra(Account.ACCOUNT_TAG, account)
}
}
itemView.context.startActivity(intent)
}
private fun Notification.openPostFromNotification(): Intent {
val intent = Intent(itemView.context, PostActivity::class.java)
intent.putExtra(Status.POST_TAG, status)
return intent
}
private fun setNotificationType(type: Notification.NotificationType, username: String,
textView: TextView
){
val context = textView.context
val (format: String, drawable: Drawable?) = when(type) {
Notification.NotificationType.follow -> {
setNotificationTypeTextView(context, R.string.followed_notification, R.drawable.ic_follow)
}
Notification.NotificationType.mention -> {
setNotificationTypeTextView(context, R.string.mention_notification, R.drawable.ic_apenstaart)
}
Notification.NotificationType.reblog -> {
setNotificationTypeTextView(context, R.string.shared_notification, R.drawable.ic_reblog_blue)
}
Notification.NotificationType.favourite -> {
setNotificationTypeTextView(context, R.string.liked_notification, R.drawable.ic_like_full)
}
Notification.NotificationType.poll -> {
setNotificationTypeTextView(context, R.string.poll_notification, R.drawable.poll)
}
}
textView.text = format.format(username)
textView.setCompoundDrawablesWithIntrinsicBounds(
drawable,null,null,null
)
}
private fun setNotificationTypeTextView(context: Context, format: Int, drawable: Int): Pair<String, Drawable?> {
return Pair(context.getString(format), ContextCompat.getDrawable(context, drawable))
}
fun bind(notification: Notification?, api: PixelfedAPI, accessToken: String) {
this.notification = notification
Glide.with(itemView).load(notification?.account?.avatar_static).circleCrop().into(avatar)
val previewUrl = notification?.status?.media_attachments?.getOrNull(0)?.preview_url
if(!previewUrl.isNullOrBlank()){
Glide.with(itemView).load(previewUrl)
.placeholder(R.drawable.ic_picture_fallback).into(photoThumbnail)
} else{
photoThumbnail.visibility = View.GONE
}
notification?.type?.let { setNotificationType(it, notification.account.username!!, notificationType) }
notification?.created_at?.let { setTextViewFromISO8601(it, notificationTime, false, itemView.context) }
//Convert HTML to clickable text
postDescription.text =
parseHTMLText(
notification?.status?.content ?: "",
notification?.status?.mentions,
api,
itemView.context,
"Bearer $accessToken"
)
}
companion object {
fun create(parent: ViewGroup): NotificationViewHolder {
val view = LayoutInflater.from(parent.context)
.inflate(R.layout.fragment_notifications, parent, false)
return NotificationViewHolder(view)
}
}
}
class NotificationsAdapter(private val apiHolder: PixelfedAPIHolder, private val db: AppDatabase) : PagingDataAdapter<Notification, RecyclerView.ViewHolder>(
UIMODEL_COMPARATOR
) {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
return NotificationViewHolder.create(parent)
}
override fun getItemViewType(position: Int): Int {
return R.layout.fragment_notifications
}
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
val uiModel = getItem(position)
uiModel.let {
(holder as NotificationViewHolder).bind(it, apiHolder.setDomainToCurrentUser(db), db.userDao().getActiveUser()!!.accessToken)
}
}
companion object {
private val UIMODEL_COMPARATOR = object : DiffUtil.ItemCallback<Notification>() {
override fun areItemsTheSame(oldItem: Notification, newItem: Notification): Boolean {
return oldItem.id == newItem.id
}
override fun areContentsTheSame(oldItem: Notification, newItem: Notification): Boolean =
oldItem == newItem
}
}
}

View File

@ -0,0 +1,86 @@
/*
* Copyright (C) 2020 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.h.pixeldroid.fragments.feeds.cachedFeeds.notifications
import androidx.paging.*
import androidx.room.withTransaction
import com.h.pixeldroid.db.AppDatabase
import com.h.pixeldroid.di.PixelfedAPIHolder
import com.h.pixeldroid.objects.Notification
import retrofit2.HttpException
import java.io.IOException
import javax.inject.Inject
/**
* RemoteMediator for the notifications.
*
* A [RemoteMediator] defines a set of callbacks used to incrementally load data from a remote
* source into a local source wrapped by a [PagingSource], e.g., loading data from network into
* a local db cache.
*/
@OptIn(ExperimentalPagingApi::class)
class NotificationsRemoteMediator @Inject constructor(
private val apiHolder: PixelfedAPIHolder,
private val db: AppDatabase
) : RemoteMediator<Int, Notification>() {
override suspend fun load(loadType: LoadType, state: PagingState<Int, Notification>): MediatorResult {
val (max_id, min_id) = when (loadType) {
LoadType.REFRESH -> {
Pair<String?, String?>(null, null)
}
LoadType.PREPEND -> {
//No prepend for the moment, might be nice to add later
return MediatorResult.Success(endOfPaginationReached = true)
}
LoadType.APPEND -> {
Pair<String?, String?>(state.lastItemOrNull()?.id, null)
}
}
try {
val user = db.userDao().getActiveUser()!!
val api = apiHolder.api ?: apiHolder.setDomainToCurrentUser(db)
val accessToken = user.accessToken.orEmpty()
val apiResponse = api.notifications("Bearer $accessToken",
max_id = max_id,
min_id = min_id,
limit = state.config.pageSize.toString(),
)
apiResponse.forEach{it.user_id = user.user_id; it.instance_uri = user.instance_uri}
val endOfPaginationReached = apiResponse.isEmpty()
db.withTransaction {
// clear table in the database
if (loadType == LoadType.REFRESH) {
db.notificationDao().clearFeedContent()
}
db.notificationDao().insertAll(apiResponse)
}
return MediatorResult.Success(endOfPaginationReached = endOfPaginationReached)
} catch (exception: IOException) {
return MediatorResult.Error(exception)
} catch (exception: HttpException) {
return MediatorResult.Error(exception)
}
}
}

View File

@ -0,0 +1,71 @@
package com.h.pixeldroid.fragments.feeds.cachedFeeds.postFeeds
import androidx.paging.*
import androidx.room.withTransaction
import com.h.pixeldroid.db.AppDatabase
import com.h.pixeldroid.di.PixelfedAPIHolder
import com.h.pixeldroid.db.entities.HomeStatusDatabaseEntity
import retrofit2.HttpException
import java.io.IOException
import javax.inject.Inject
/**
* RemoteMediator for the home feed.
*
* A [RemoteMediator] defines a set of callbacks used to incrementally load data from a remote
* source into a local source wrapped by a [PagingSource], e.g., loading data from network into
* a local db cache.
*/
@OptIn(ExperimentalPagingApi::class)
class HomeFeedRemoteMediator @Inject constructor(
private val apiHolder: PixelfedAPIHolder,
private val db: AppDatabase,
) : RemoteMediator<Int, HomeStatusDatabaseEntity>() {
override suspend fun load(loadType: LoadType, state: PagingState<Int, HomeStatusDatabaseEntity>): MediatorResult {
val (max_id, min_id) = when (loadType) {
LoadType.REFRESH -> {
Pair<String?, String?>(null, null)
}
LoadType.PREPEND -> {
//No prepend for the moment, might be nice to add later
return MediatorResult.Success(endOfPaginationReached = true)
}
LoadType.APPEND -> {
Pair<String?, String?>(state.lastItemOrNull()?.id, null)
}
}
try {
val user = db.userDao().getActiveUser()!!
val api = apiHolder.api ?: apiHolder.setDomainToCurrentUser(db)
val accessToken = user.accessToken.orEmpty()
val apiResponse = api.timelineHome( "Bearer $accessToken",
max_id= max_id, min_id = min_id,
limit = state.config.pageSize.toString())
val dbObjects = apiResponse.map{
HomeStatusDatabaseEntity(user.user_id, user.instance_uri, it)
}
val endOfPaginationReached = apiResponse.isEmpty()
db.withTransaction {
// clear table in the database
if (loadType == LoadType.REFRESH) {
db.homePostDao().clearFeedContent()
}
db.homePostDao().insertAll(dbObjects)
}
return MediatorResult.Success(endOfPaginationReached = endOfPaginationReached)
} catch (exception: IOException) {
return MediatorResult.Error(exception)
} catch (exception: HttpException) {
return MediatorResult.Error(exception)
}
}
}

View File

@ -0,0 +1,97 @@
package com.h.pixeldroid.fragments.feeds.cachedFeeds.postFeeds
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.lifecycle.ViewModelProvider
import androidx.paging.ExperimentalPagingApi
import androidx.paging.PagingDataAdapter
import androidx.paging.RemoteMediator
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.RecyclerView
import com.h.pixeldroid.R
import com.h.pixeldroid.db.dao.feedContent.FeedContentDao
import com.h.pixeldroid.fragments.StatusViewHolder
import com.h.pixeldroid.fragments.feeds.cachedFeeds.FeedViewModel
import com.h.pixeldroid.fragments.feeds.cachedFeeds.CachedFeedFragment
import com.h.pixeldroid.fragments.feeds.cachedFeeds.ViewModelFactory
import com.h.pixeldroid.objects.FeedContentDatabase
import com.h.pixeldroid.objects.Status
/**
* Fragment for the home feed or public feed tabs.
*
* Takes a "home" boolean in its arguments [Bundle] to determine which
*/
@ExperimentalPagingApi
class PostFeedFragment<T: FeedContentDatabase>: CachedFeedFragment<T>() {
private lateinit var mediator: RemoteMediator<Int, T>
private lateinit var dao: FeedContentDao<T>
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
adapter = PostsAdapter()
@Suppress("UNCHECKED_CAST")
if (requireArguments().get("home") as Boolean){
mediator = HomeFeedRemoteMediator(apiHolder, db) as RemoteMediator<Int, T>
dao = db.homePostDao() as FeedContentDao<T>
}
else {
mediator = PublicFeedRemoteMediator(apiHolder, db) as RemoteMediator<Int, T>
dao = db.publicPostDao() as FeedContentDao<T>
}
}
@ExperimentalPagingApi
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
val view = super.onCreateView(inflater, container, savedInstanceState)
// get the view model
@Suppress("UNCHECKED_CAST")
viewModel = ViewModelProvider(this, ViewModelFactory(db, dao, mediator))
.get(FeedViewModel::class.java) as FeedViewModel<T>
launch()
initSearch()
return view
}
inner class PostsAdapter : PagingDataAdapter<T, RecyclerView.ViewHolder>(
object : DiffUtil.ItemCallback<T>() {
override fun areItemsTheSame(oldItem: T, newItem: T): Boolean {
return oldItem.id == newItem.id
}
override fun areContentsTheSame(oldItem: T, newItem: T): Boolean =
oldItem.id == newItem.id
}
) {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
return StatusViewHolder.create(parent)
}
override fun getItemViewType(position: Int): Int {
return R.layout.post_fragment
}
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
val uiModel = getItem(position) as Status
uiModel.let {
val instanceUri = db.userDao().getActiveUser()!!.instance_uri
val accessToken = db.userDao().getActiveUser()!!.accessToken
(holder as StatusViewHolder).bind(it, instanceUri, apiHolder.setDomain(instanceUri), "Bearer $accessToken")
}
}
}
}

View File

@ -0,0 +1,86 @@
/*
* Copyright (C) 2020 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.h.pixeldroid.fragments.feeds.cachedFeeds.postFeeds
import androidx.paging.*
import androidx.room.withTransaction
import com.h.pixeldroid.db.AppDatabase
import com.h.pixeldroid.db.entities.PublicFeedStatusDatabaseEntity
import com.h.pixeldroid.di.PixelfedAPIHolder
import retrofit2.HttpException
import java.io.IOException
import javax.inject.Inject
/**
* RemoteMediator for the public feed.
*
* A [RemoteMediator] defines a set of callbacks used to incrementally load data from a remote
* source into a local source wrapped by a [PagingSource], e.g., loading data from network into
* a local db cache.
*/
@OptIn(ExperimentalPagingApi::class)
class PublicFeedRemoteMediator @Inject constructor(
private val apiHolder: PixelfedAPIHolder,
private val db: AppDatabase
) : RemoteMediator<Int, PublicFeedStatusDatabaseEntity>() {
override suspend fun load(loadType: LoadType, state: PagingState<Int, PublicFeedStatusDatabaseEntity>): MediatorResult {
val (max_id, min_id) = when (loadType) {
LoadType.REFRESH -> {
Pair<String?, String?>(null, null)
}
LoadType.PREPEND -> {
//No prepend for the moment, might be nice to add later
return MediatorResult.Success(endOfPaginationReached = true)
}
LoadType.APPEND -> {
Pair<String?, String?>(state.lastItemOrNull()?.id, null)
}
}
try {
val user = db.userDao().getActiveUser()!!
val api = apiHolder.api ?: apiHolder.setDomainToCurrentUser(db)
val apiResponse = api.timelinePublic(
max_id = max_id,
min_id = min_id,
limit = state.config.pageSize.toString(),
)
val dbObjects = apiResponse.map{
PublicFeedStatusDatabaseEntity(user.user_id, user.instance_uri, it)
}
val endOfPaginationReached = apiResponse.isEmpty()
db.withTransaction {
// clear table in the database
if (loadType == LoadType.REFRESH) {
db.publicPostDao().clearFeedContent()
}
db.publicPostDao().insertAll(dbObjects)
}
return MediatorResult.Success(endOfPaginationReached = endOfPaginationReached)
} catch (exception: IOException) {
return MediatorResult.Error(exception)
} catch (exception: HttpException) {
return MediatorResult.Error(exception)
}
}
}

View File

@ -1,70 +0,0 @@
package com.h.pixeldroid.fragments.feeds.postFeeds
import android.util.Log
import android.view.View
import android.widget.Toast
import androidx.lifecycle.LiveData
import androidx.paging.LivePagedListBuilder
import androidx.paging.PagedList
import com.h.pixeldroid.R
import com.h.pixeldroid.objects.Status
import com.h.pixeldroid.utils.DBUtils
import retrofit2.Call
import retrofit2.Callback
import retrofit2.Response
class HomeTimelineFragment: PostsFeedFragment() {
override fun makeContent(): LiveData<PagedList<Status>> {
val config: PagedList.Config = PagedList.Config.Builder().setPageSize(10).build()
val dataSource = PostFeedDataSource()
factory = FeedDataSourceFactory(dataSource)
return LivePagedListBuilder(factory, config).build()
}
inner class PostFeedDataSource: FeedDataSource<String, Status>() {
override fun newSource(): PostFeedDataSource {
return PostFeedDataSource()
}
override fun makeInitialCall(requestedLoadSize: Int): Call<List<Status>> {
return pixelfedAPI
.timelineHome("Bearer $accessToken", limit="$requestedLoadSize")
}
override fun makeAfterCall(requestedLoadSize: Int, key: String): Call<List<Status>> {
return pixelfedAPI
.timelineHome("Bearer $accessToken", max_id=key,
limit="$requestedLoadSize")
}
//We use the id as the key
override fun getKey(item: Status): String {
return item.id!!
}
override fun enqueueCall(call: Call<List<Status>>, callback: LoadCallback<Status>){
call.enqueue(object : Callback<List<Status>> {
override fun onResponse(call: Call<List<Status>>, response: Response<List<Status>>) {
if (response.isSuccessful && response.body() != null) {
val notifications = response.body()!!
callback.onResult(notifications)
DBUtils.storePosts(db, notifications, user!!)
} else {
showError()
}
swipeRefreshLayout.isRefreshing = false
loadingIndicator.visibility = View.GONE
}
override fun onFailure(call: Call<List<Status>>, t: Throwable) {
showError(errorText = R.string.feed_failed)
Log.e("PostsFeedFragment", t.toString())
}
})
}
}
}

View File

@ -1,152 +0,0 @@
package com.h.pixeldroid.fragments.feeds.postFeeds
import android.graphics.Color
import android.graphics.drawable.ColorDrawable
import android.graphics.drawable.Drawable
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.*
import androidx.lifecycle.LiveData
import androidx.lifecycle.Observer
import androidx.paging.PagedList
import androidx.recyclerview.widget.RecyclerView
import androidx.viewpager2.widget.ViewPager2
import at.connyduck.sparkbutton.SparkButton
import com.bumptech.glide.Glide
import com.bumptech.glide.ListPreloader
import com.bumptech.glide.RequestBuilder
import com.bumptech.glide.integration.recyclerview.RecyclerViewPreloader
import com.bumptech.glide.util.ViewPreloadSizeProvider
import com.h.pixeldroid.R
import com.h.pixeldroid.fragments.feeds.FeedFragment
import com.h.pixeldroid.fragments.feeds.FeedsRecyclerViewAdapter
import com.h.pixeldroid.objects.Status
abstract class PostsFeedFragment : FeedFragment() {
lateinit var picRequest: RequestBuilder<Drawable>
lateinit var domain : String
protected lateinit var adapter : FeedsRecyclerViewAdapter<Status, PostViewHolder>
lateinit var factory: FeedDataSourceFactory<String, Status>
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
val view = super.onCreateView(inflater, container, savedInstanceState)
domain = user?.instance_uri.orEmpty()
//RequestBuilder that is re-used for every image
picRequest = Glide.with(this)
.asDrawable().fitCenter()
.placeholder(ColorDrawable(Color.GRAY))
adapter = PostsFeedRecyclerViewAdapter()
list.adapter = adapter
//Make Glide be aware of the recyclerview and pre-load images
val sizeProvider: ListPreloader.PreloadSizeProvider<Status> = ViewPreloadSizeProvider()
val preloader: RecyclerViewPreloader<Status> = RecyclerViewPreloader(
Glide.with(this), adapter as PostsFeedRecyclerViewAdapter, sizeProvider, 4
)
list.addOnScrollListener(preloader)
return view
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
val content = makeContent()
content.observe(viewLifecycleOwner,
{ c ->
adapter.submitList(c)
//after a refresh is done we need to stop the pull to refresh spinner
swipeRefreshLayout.isRefreshing = false
})
swipeRefreshLayout.setOnRefreshListener {
showError(show = false)
//by invalidating data, loadInitial will be called again
factory.liveData.value!!.invalidate()
}
}
internal abstract fun makeContent(): LiveData<PagedList<Status>>
/**
* [RecyclerView.Adapter] that can display a list of Statuses
*/
inner class PostsFeedRecyclerViewAdapter
: FeedsRecyclerViewAdapter<Status, PostViewHolder>(),
ListPreloader.PreloadModelProvider<Status> {
private val api = pixelfedAPI
private val credential = "Bearer $accessToken"
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): PostViewHolder {
val view = LayoutInflater.from(parent.context)
.inflate(R.layout.post_fragment, parent, false)
context = view.context
return PostViewHolder(
view,
context
)
}
/**
* Binds the different elements of the Post Model to the view holder
*/
override fun onBindViewHolder(holder: PostViewHolder, position: Int) {
val post = getItem(position) ?: return
val metrics = context.resources.displayMetrics
//Limit the height of the different images
holder.postPic.maxHeight = metrics.heightPixels * 3/4
//Setup the post layout
post.setupPost(holder.postView, picRequest, this@PostsFeedFragment, domain, false)
post.activateButtons(holder, api, credential)
}
override fun getPreloadItems(position: Int): MutableList<Status> {
val status = getItem(position) ?: return mutableListOf()
return mutableListOf(status)
}
override fun getPreloadRequestBuilder(item: Status): RequestBuilder<*>? {
return picRequest.load(item.getPostUrl())
}
}
}
/**
* Represents the posts that will be contained within the feed
*/
class PostViewHolder(val postView: View, val context: android.content.Context) : RecyclerView.ViewHolder(postView) {
val profilePic : ImageView = postView.findViewById(R.id.profilePic)
val postPic : ImageView = postView.findViewById(R.id.postPicture)
val username : TextView = postView.findViewById(R.id.username)
val usernameDesc: TextView = postView.findViewById(R.id.usernameDesc)
val description : TextView = postView.findViewById(R.id.description)
val nlikes : TextView = postView.findViewById(R.id.nlikes)
val nshares : TextView = postView.findViewById(R.id.nshares)
//Spark buttons
val liker : SparkButton = postView.findViewById(R.id.liker)
val reblogger : SparkButton = postView.findViewById(R.id.reblogger)
val submitCmnt : ImageButton = postView.findViewById(R.id.submitComment)
val commenter : ImageView = postView.findViewById(R.id.commenter)
val comment : EditText = postView.findViewById(R.id.editComment)
val commentCont : LinearLayout = postView.findViewById(R.id.commentContainer)
val commentIn : LinearLayout = postView.findViewById(R.id.commentIn)
val viewComment : TextView = postView.findViewById(R.id.ViewComments)
val postDate : TextView = postView.findViewById(R.id.postDate)
val postDomain : TextView = postView.findViewById(R.id.postDomain)
val sensitiveW : TextView = postView.findViewById(R.id.sensitiveWarning)
val postPager : ViewPager2 = postView.findViewById(R.id.postPager)
val more : ImageButton = postView.findViewById(R.id.status_more)
}

View File

@ -1,60 +0,0 @@
package com.h.pixeldroid.fragments.feeds.postFeeds
import android.util.Log
import android.view.View
import android.widget.Toast
import androidx.lifecycle.LiveData
import androidx.paging.LivePagedListBuilder
import androidx.paging.PagedList
import com.h.pixeldroid.R
import com.h.pixeldroid.objects.Status
import retrofit2.Call
import retrofit2.Callback
import retrofit2.Response
class PublicTimelineFragment: PostsFeedFragment() {
inner class PublicFeedDataSource : FeedDataSource<String, Status>(){
override fun newSource(): PublicFeedDataSource {
return PublicFeedDataSource()
}
override fun makeInitialCall(requestedLoadSize: Int): Call<List<Status>> {
return pixelfedAPI.timelinePublic(limit="$requestedLoadSize")
}
override fun makeAfterCall(requestedLoadSize: Int, key: String): Call<List<Status>> {
return pixelfedAPI.timelinePublic( max_id=key, limit="$requestedLoadSize")
}
override fun enqueueCall(call: Call<List<Status>>, callback: LoadCallback<Status>) {
call.enqueue(object : Callback<List<Status>> {
override fun onResponse(call: Call<List<Status>>, response: Response<List<Status>>) {
if (response.isSuccessful && response.body() != null) {
val notifications = response.body()!!
callback.onResult(notifications)
} else{
showError()
}
swipeRefreshLayout.isRefreshing = false
loadingIndicator.visibility = View.GONE
}
override fun onFailure(call: Call<List<Status>>, t: Throwable) {
showError(errorText = R.string.feed_failed)
Log.e("PublicTimelineFragment", t.toString())
}
})
}
override fun getKey(item: Status): String {
return item.id!!
}
}
override fun makeContent(): LiveData<PagedList<Status>> {
val config: PagedList.Config = PagedList.Config.Builder().setPageSize(10).build()
factory = FeedDataSourceFactory(PublicFeedDataSource())
return LivePagedListBuilder(factory, config).build()
}
}

View File

@ -1,110 +0,0 @@
package com.h.pixeldroid.fragments.feeds.search
import android.os.Bundle
import android.util.Log
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.Toast
import androidx.lifecycle.LiveData
import androidx.paging.LivePagedListBuilder
import androidx.paging.PagedList
import com.h.pixeldroid.R
import com.h.pixeldroid.fragments.feeds.AccountListFragment
import com.h.pixeldroid.fragments.feeds.FeedFragment
import com.h.pixeldroid.objects.Account
import com.h.pixeldroid.objects.Results
import com.h.pixeldroid.objects.Tag
import retrofit2.Call
import retrofit2.Callback
import retrofit2.Response
class SearchAccountFragment: AccountListFragment(){
private lateinit var query: String
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
val view = super.onCreateView(inflater, container, savedInstanceState)
query = arguments?.getSerializable("searchFeed") as String
return view
}
inner class SearchAccountListDataSource: FeedDataSource<String, Account>(){
override fun newSource(): SearchAccountListDataSource {
return SearchAccountListDataSource()
}
override fun getKey(item: Account): String {
return content.value?.loadedCount.toString()
}
private fun searchMakeInitialCall(requestedLoadSize: Int): Call<Results> {
return pixelfedAPI
.search("Bearer $accessToken",
limit="$requestedLoadSize", q = query,
type = Results.SearchType.accounts)
}
private fun searchMakeAfterCall(requestedLoadSize: Int, key: String): Call<Results> {
return pixelfedAPI
.search("Bearer $accessToken", offset = key,
limit="$requestedLoadSize", q = query,
type = Results.SearchType.accounts)
}
override fun loadInitial(
params: LoadInitialParams<String>,
callback: LoadInitialCallback<Account>
) {
searchEnqueueCall(searchMakeInitialCall(params.requestedLoadSize), callback)
}
override fun loadAfter(params: LoadParams<String>, callback: LoadCallback<Account>) {
searchEnqueueCall(searchMakeAfterCall(params.requestedLoadSize, params.key), callback)
}
private fun searchEnqueueCall(call: Call<Results>, callback: LoadCallback<Account>) {
call.enqueue(object : Callback<Results> {
override fun onResponse(call: Call<Results>, response: Response<Results>) {
if (response.code() == 200) {
val notifications = response.body()!!.accounts as ArrayList<Account>
callback.onResult(notifications as List<Account>)
} else{
showError()
}
swipeRefreshLayout.isRefreshing = false
loadingIndicator.visibility = View.GONE
}
override fun onFailure(call: Call<Results>, t: Throwable) {
showError(errorText = R.string.feed_failed)
Log.e("FeedFragment", t.toString())
}
})
}
override fun makeInitialCall(requestedLoadSize: Int): Call<List<Account>> {
throw NotImplementedError("Should not be called, reimplemented for Search fragment")
}
override fun makeAfterCall(requestedLoadSize: Int, key: String): Call<List<Account>> {
throw NotImplementedError("Should not be called, reimplemented for Search fragment")
}
override fun enqueueCall(call: Call<List<Account>>, callback: LoadCallback<Account>) {
throw NotImplementedError("Should not be called, reimplemented for Search fragment")
}
}
override fun makeContent(): LiveData<PagedList<Account>> {
val config: PagedList.Config = PagedList.Config.Builder().setPageSize(10).build()
factory = FeedFragment().FeedDataSourceFactory(SearchAccountListDataSource())
return LivePagedListBuilder(factory, config).build()
}
}

View File

@ -1,170 +0,0 @@
package com.h.pixeldroid.fragments.feeds.search
import android.annotation.SuppressLint
import android.os.Bundle
import android.util.Log
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.TextView
import android.widget.Toast
import androidx.lifecycle.LiveData
import androidx.lifecycle.Observer
import androidx.paging.LivePagedListBuilder
import androidx.paging.PagedList
import androidx.recyclerview.widget.RecyclerView
import com.h.pixeldroid.R
import com.h.pixeldroid.fragments.feeds.AccountListFragment
import com.h.pixeldroid.fragments.feeds.FeedFragment
import com.h.pixeldroid.fragments.feeds.FeedsRecyclerViewAdapter
import com.h.pixeldroid.objects.Account
import com.h.pixeldroid.objects.Results
import com.h.pixeldroid.objects.Tag
import kotlinx.android.synthetic.main.fragment_tags.view.*
import retrofit2.Call
import retrofit2.Callback
import retrofit2.Response
class SearchHashtagFragment: FeedFragment(){
private lateinit var query: String
private lateinit var content: LiveData<PagedList<Tag>>
private lateinit var adapter : TagsRecyclerViewAdapter
lateinit var factory: FeedDataSourceFactory<Int, Tag>
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
val view = super.onCreateView(inflater, container, savedInstanceState)
query = arguments?.getSerializable("searchFeed") as String
adapter = TagsRecyclerViewAdapter()
list.adapter = adapter
return view
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
content = makeContent()
content.observe(viewLifecycleOwner,
Observer { c ->
adapter.submitList(c)
//after a refresh is done we need to stop the pull to refresh spinner
swipeRefreshLayout.isRefreshing = false
})
swipeRefreshLayout.setOnRefreshListener {
showError(show = false)
//by invalidating data, loadInitial will be called again
factory.liveData.value!!.invalidate()
}
}
inner class SearchTagsListDataSource: FeedDataSource<Int, Tag>(){
override fun newSource(): SearchTagsListDataSource {
return SearchTagsListDataSource()
}
private fun searchMakeInitialCall(requestedLoadSize: Int): Call<Results> {
return pixelfedAPI
.search("Bearer $accessToken",
limit="$requestedLoadSize", q=query,
type = Results.SearchType.hashtags)
}
private fun searchMakeAfterCall(requestedLoadSize: Int, key: Int): Call<Results> {
return pixelfedAPI
.search("Bearer $accessToken", offset = key.toString(),
limit="$requestedLoadSize", q = query,
type = Results.SearchType.hashtags)
}
override fun getKey(item: Tag): Int {
val value = content.value
return value?.loadedCount ?: 0
}
override fun loadInitial(
params: LoadInitialParams<Int>,
callback: LoadInitialCallback<Tag>
) {
searchEnqueueCall(searchMakeInitialCall(params.requestedLoadSize), callback)
}
override fun loadAfter(params: LoadParams<Int>, callback: LoadCallback<Tag>) {
searchEnqueueCall(searchMakeAfterCall(params.requestedLoadSize, params.key), callback)
}
private fun searchEnqueueCall(call: Call<Results>, callback: LoadCallback<Tag>){
call.enqueue(object : Callback<Results> {
override fun onResponse(call: Call<Results>, response: Response<Results>) {
if (response.code() == 200) {
val notifications = response.body()!!.hashtags as ArrayList<Tag>
callback.onResult(notifications as List<Tag>)
} else{
showError()
}
swipeRefreshLayout.isRefreshing = false
loadingIndicator.visibility = View.GONE
}
override fun onFailure(call: Call<Results>, t: Throwable) {
showError(errorText = R.string.feed_failed)
Log.e("FeedFragment", t.toString())
}
})
}
override fun makeInitialCall(requestedLoadSize: Int): Call<List<Tag>> {
throw NotImplementedError("Should not be called, reimplemented for Search fragment")
}
override fun makeAfterCall(requestedLoadSize: Int, key: Int): Call<List<Tag>> {
throw NotImplementedError("Should not be called, reimplemented for Search fragment")
}
override fun enqueueCall(call: Call<List<Tag>>, callback: LoadCallback<Tag>) {
throw NotImplementedError("Should not be called, reimplemented for Search fragment")
}
}
private fun makeContent(): LiveData<PagedList<Tag>> {
val config: PagedList.Config = PagedList.Config.Builder().setPageSize(10).build()
factory =
FeedFragment()
.FeedDataSourceFactory(
SearchTagsListDataSource()
)
return LivePagedListBuilder(factory, config).build()
}
inner class TagsRecyclerViewAdapter : FeedsRecyclerViewAdapter<Tag, TagsRecyclerViewAdapter.ViewHolder>() {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
val view = LayoutInflater.from(parent.context)
.inflate(R.layout.fragment_tags, parent, false)
context = view.context
return ViewHolder(view)
}
override fun onBindViewHolder(holder : ViewHolder, position : Int) {
val tag = getItem(position) ?: return
@SuppressLint("SetTextI18n")
holder.name.text = "#" + tag.name
holder.mView.setOnClickListener { Log.e("Tag: ", tag.name) }
}
inner class ViewHolder(val mView : View) : RecyclerView.ViewHolder(mView) {
val name : TextView = mView.tag_name
}
}
}

View File

@ -1,110 +0,0 @@
package com.h.pixeldroid.fragments.feeds.search
import android.os.Bundle
import android.util.Log
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.lifecycle.LiveData
import androidx.paging.LivePagedListBuilder
import androidx.paging.PagedList
import com.h.pixeldroid.R
import com.h.pixeldroid.fragments.feeds.FeedFragment
import com.h.pixeldroid.fragments.feeds.postFeeds.PostsFeedFragment
import com.h.pixeldroid.objects.Results
import com.h.pixeldroid.objects.Status
import retrofit2.Call
import retrofit2.Callback
import retrofit2.Response
class SearchPostsFragment: PostsFeedFragment(){
private lateinit var query: String
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
val view = super.onCreateView(inflater, container, savedInstanceState)
query = arguments?.getSerializable("searchFeed") as String
return view
}
inner class SearchFeedDataSource : FeedDataSource<String, Status>(){
override fun newSource(): SearchFeedDataSource {
return SearchFeedDataSource()
}
private fun searchMakeInitialCall(requestedLoadSize: Int): Call<Results> {
return pixelfedAPI
.search("Bearer $accessToken",
limit="$requestedLoadSize", q=query,
type = Results.SearchType.statuses)
}
private fun searchMakeAfterCall(requestedLoadSize: Int, key: String): Call<Results> {
return pixelfedAPI
.search("Bearer $accessToken", max_id=key,
limit="$requestedLoadSize", q = query,
type = Results.SearchType.statuses)
}
override fun loadInitial(
params: LoadInitialParams<String>,
callback: LoadInitialCallback<Status>
) {
searchEnqueueCall(searchMakeInitialCall(params.requestedLoadSize), callback)
}
override fun loadAfter(params: LoadParams<String>, callback: LoadCallback<Status>) {
searchEnqueueCall(searchMakeAfterCall(params.requestedLoadSize, params.key), callback)
}
private fun searchEnqueueCall(call: Call<Results>, callback: LoadCallback<Status>){
call.enqueue(object : Callback<Results> {
override fun onResponse(call: Call<Results>, response: Response<Results>) {
if (response.code() == 200) {
val notifications = response.body()!!.statuses as ArrayList<Status>
callback.onResult(notifications as List<Status>)
} else {
showError()
}
swipeRefreshLayout.isRefreshing = false
loadingIndicator.visibility = View.GONE
}
override fun onFailure(call: Call<Results>, t: Throwable) {
showError(errorText = R.string.feed_failed)
Log.e("FeedFragment", t.toString())
}
})
}
override fun makeInitialCall(requestedLoadSize: Int): Call<List<Status>> {
throw NotImplementedError("Should not be called, reimplemented for Search fragment")
}
override fun makeAfterCall(requestedLoadSize: Int, key: String): Call<List<Status>> {
throw NotImplementedError("Should not be called, reimplemented for Search fragment")
}
override fun enqueueCall(call: Call<List<Status>>, callback: LoadCallback<Status>) {
throw NotImplementedError("Should not be called, reimplemented for Search fragment")
}
override fun getKey(item: Status): String {
return item.id!!
}
}
override fun makeContent(): LiveData<PagedList<Status>> {
val config: PagedList.Config = PagedList.Config.Builder().setPageSize(10).build()
factory = FeedFragment()
.FeedDataSourceFactory(SearchFeedDataSource())
return LivePagedListBuilder(factory, config).build()
}
}

View File

@ -0,0 +1,34 @@
package com.h.pixeldroid.fragments.feeds.uncachedFeeds
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.paging.*
import com.h.pixeldroid.objects.FeedContent
import kotlinx.coroutines.flow.Flow
/**
* ViewModel for the uncached feeds.
* The ViewModel works with the different [UncachedContentRepository]s to get the data.
*/
class FeedViewModel<T: FeedContent>(private val repository: UncachedContentRepository<T>) : ViewModel() {
private var currentResult: Flow<PagingData<T>>? = null
fun flow(): Flow<PagingData<T>> {
val lastResult = currentResult
if (lastResult != null) {
return lastResult
}
val newResult: Flow<PagingData<T>> = repository.getStream()
.cachedIn(viewModelScope)
currentResult = newResult
return newResult
}
}
/**
* Common interface for the different uncached feeds
*/
interface UncachedContentRepository<T: FeedContent>{
fun getStream(): Flow<PagingData<T>>
}

View File

@ -0,0 +1,95 @@
package com.h.pixeldroid.fragments.feeds.uncachedFeeds
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.lifecycleScope
import androidx.paging.*
import androidx.recyclerview.widget.RecyclerView
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.distinctUntilChangedBy
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.launch
import com.h.pixeldroid.databinding.FragmentFeedBinding
import com.h.pixeldroid.fragments.BaseFragment
import com.h.pixeldroid.fragments.feeds.initAdapter
import com.h.pixeldroid.objects.FeedContent
/**
* A fragment representing a list of [FeedContent], not backed by a db cache.
*/
open class UncachedFeedFragment<T: FeedContent> : BaseFragment() {
internal lateinit var viewModel: FeedViewModel<T>
internal lateinit var adapter: PagingDataAdapter<T, RecyclerView.ViewHolder>
private lateinit var binding: FragmentFeedBinding
private var job: Job? = null
internal fun launch() {
// Make sure we cancel the previous job before creating a new one
job?.cancel()
job = lifecycleScope.launch {
viewModel.flow().collectLatest {
adapter.submitData(it)
}
}
}
internal fun initSearch() {
// Scroll to top when the list is refreshed from network.
lifecycleScope.launch {
adapter.loadStateFlow
// Only emit when REFRESH LoadState for RemoteMediator changes.
.distinctUntilChangedBy { it.refresh }
// Only react to cases where Remote REFRESH completes i.e., NotLoading.
.filter { it.refresh is LoadState.NotLoading }
.collect { binding.list.scrollToPosition(0) }
}
}
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
super.onCreateView(inflater, container, savedInstanceState)
binding = FragmentFeedBinding.inflate(layoutInflater)
initAdapter(binding, adapter)
binding.swipeRefreshLayout.setOnRefreshListener {
//It shouldn't be necessary to also retry() in addition to refresh(),
//but if we don't do this, reloads after an error fail immediately...
adapter.retry()
adapter.refresh()
}
return binding.root
}
}
class ViewModelFactory<U: FeedContent> @ExperimentalPagingApi constructor(
private val searchContentRepository: UncachedContentRepository<U>
) : ViewModelProvider.Factory {
@ExperimentalPagingApi
override fun <T : ViewModel> create(modelClass: Class<T>): T {
if (modelClass.isAssignableFrom(FeedViewModel::class.java)) {
@Suppress("UNCHECKED_CAST")
return FeedViewModel(searchContentRepository) as T
}
throw IllegalArgumentException("Unknown ViewModel class")
}
}

View File

@ -0,0 +1,145 @@
package com.h.pixeldroid.fragments.feeds.uncachedFeeds.accountLists
import android.annotation.SuppressLint
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.ImageView
import android.widget.TextView
import androidx.lifecycle.ViewModelProvider
import androidx.paging.ExperimentalPagingApi
import androidx.paging.PagingDataAdapter
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.RecyclerView
import com.bumptech.glide.Glide
import com.h.pixeldroid.R
import com.h.pixeldroid.fragments.feeds.uncachedFeeds.FeedViewModel
import com.h.pixeldroid.fragments.feeds.uncachedFeeds.UncachedFeedFragment
import com.h.pixeldroid.fragments.feeds.uncachedFeeds.ViewModelFactory
import com.h.pixeldroid.objects.Account
import com.h.pixeldroid.objects.Account.Companion.ACCOUNT_ID_TAG
import com.h.pixeldroid.objects.Account.Companion.FOLLOWERS_TAG
import kotlinx.android.synthetic.main.account_list_entry.view.*
/**
* Fragment to show a list of [Account]s, for a list of followers or following
*/
class AccountListFragment : UncachedFeedFragment<Account>() {
private lateinit var id: String
private var following: Boolean = false
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
id = arguments?.getSerializable(ACCOUNT_ID_TAG) as String
following = arguments?.getSerializable(FOLLOWERS_TAG) as Boolean
adapter = AccountAdapter()
}
@ExperimentalPagingApi
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
val view = super.onCreateView(inflater, container, savedInstanceState)
// get the view model
@Suppress("UNCHECKED_CAST")
viewModel = ViewModelProvider(this, ViewModelFactory(
FollowersContentRepository(
apiHolder.setDomainToCurrentUser(db),
db.userDao().getActiveUser()!!.accessToken,
id,
following
)
)
)
.get(FeedViewModel::class.java) as FeedViewModel<Account>
launch()
initSearch()
return view
}
}
/**
* View Holder for an [Account] RecyclerView list item.
*/
class AccountViewHolder(view: View) : RecyclerView.ViewHolder(view) {
private val avatar : ImageView = view.account_entry_avatar
private val username : TextView = view.account_entry_username
private val acct: TextView = view.account_entry_acct
private var account: Account? = null
init {
itemView.setOnClickListener {
account?.openProfile(itemView.context)
}
}
fun bind(account: Account?) {
this.account = account
Glide.with(itemView)
.load(account?.avatar_static ?: account?.avatar)
.circleCrop().placeholder(R.drawable.ic_default_user)
.into(avatar)
username.text = account?.username
@SuppressLint("SetTextI18n")
acct.text = "@${account?.acct}"
}
companion object {
fun create(parent: ViewGroup): AccountViewHolder {
val view = LayoutInflater.from(parent.context)
.inflate(R.layout.account_list_entry, parent, false)
return AccountViewHolder(view)
}
}
}
class AccountAdapter : PagingDataAdapter<Account, RecyclerView.ViewHolder>(
UIMODEL_COMPARATOR
) {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
return AccountViewHolder.create(parent)
}
override fun getItemViewType(position: Int): Int {
return R.layout.account_list_entry
}
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
val uiModel = getItem(position)
uiModel.let {
(holder as AccountViewHolder).bind(it)
}
}
companion object {
private val UIMODEL_COMPARATOR = object : DiffUtil.ItemCallback<Account>() {
override fun areItemsTheSame(oldItem: Account, newItem: Account): Boolean {
return oldItem.id == newItem.id
}
override fun areContentsTheSame(oldItem: Account, newItem: Account): Boolean =
oldItem == newItem
}
}
}

View File

@ -0,0 +1,36 @@
package com.h.pixeldroid.fragments.feeds.uncachedFeeds.accountLists
import androidx.paging.ExperimentalPagingApi
import androidx.paging.Pager
import androidx.paging.PagingConfig
import androidx.paging.PagingData
import com.h.pixeldroid.api.PixelfedAPI
import com.h.pixeldroid.fragments.feeds.uncachedFeeds.UncachedContentRepository
import com.h.pixeldroid.objects.Account
import kotlinx.coroutines.flow.Flow
import javax.inject.Inject
class FollowersContentRepository @ExperimentalPagingApi
@Inject constructor(
private val api: PixelfedAPI,
private val accessToken: String,
private val accountId: String,
private val following: Boolean,
): UncachedContentRepository<Account> {
override fun getStream(): Flow<PagingData<Account>> {
return Pager(
config = PagingConfig(
initialLoadSize = NETWORK_PAGE_SIZE,
pageSize = NETWORK_PAGE_SIZE,
enablePlaceholders = false),
pagingSourceFactory = {
FollowersPagingSource(api, accessToken, accountId, following)
}
).flow
}
companion object {
private const val NETWORK_PAGE_SIZE = 20
}
}

View File

@ -0,0 +1,68 @@
package com.h.pixeldroid.fragments.feeds.uncachedFeeds.accountLists
import androidx.paging.PagingSource
import com.h.pixeldroid.api.PixelfedAPI
import com.h.pixeldroid.objects.Account
import retrofit2.HttpException
import java.io.IOException
class FollowersPagingSource(
private val api: PixelfedAPI,
private val accessToken: String,
private val accountId: String,
private val following: Boolean
) : PagingSource<Int, Account>() {
override suspend fun load(params: LoadParams<Int>): LoadResult<Int, Account> {
val position = params.key
return try {
val response =
// Pixelfed and Mastodon don't implement this in the same fashion. Pixelfed uses
// Laravel's paging mechanism, while Mastodon uses the Link header for pagination.
// No need to know which is which, they should ignore the non-relevant argument
if(following) {
api.followers(account_id = accountId,
authorization = "Bearer $accessToken",
limit = params.loadSize,
page = position?.toString(),
max_id = position?.toString())
} else {
api.following(account_id = accountId,
authorization = "Bearer $accessToken",
limit = params.loadSize,
page = position?.toString(),
max_id = position?.toString())
}
val accounts = if(response.isSuccessful){
response.body().orEmpty()
} else {
throw HttpException(response)
}
val nextPosition = if(response.headers()["Link"] != null){
//Header is of the form:
// Link: <https://mastodon.social/api/v1/accounts/1/followers?limit=2&max_id=7628164>; rel="next", <https://mastodon.social/api/v1/accounts/1/followers?limit=2&since_id=7628165>; rel="prev"
// So we want the first max_id value. In case there are arguments after
// the max_id in the URL, we make sure to stop at the first '?'
response.headers()["Link"]
.orEmpty()
.substringAfter("max_id=")
.substringBefore('?')
.substringBefore('>')
.toIntOrNull() ?: 0
} else {
params.key?.plus(1) ?: 2
}
LoadResult.Page(
data = accounts,
prevKey = null,
nextKey = if (accounts.isEmpty()) null else nextPosition
)
} catch (exception: IOException) {
LoadResult.Error(exception)
} catch (exception: HttpException) {
LoadResult.Error(exception)
}
}
}

View File

@ -0,0 +1,57 @@
package com.h.pixeldroid.fragments.feeds.uncachedFeeds.search
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.lifecycle.ViewModelProvider
import androidx.paging.ExperimentalPagingApi
import com.h.pixeldroid.fragments.feeds.uncachedFeeds.*
import com.h.pixeldroid.fragments.feeds.uncachedFeeds.accountLists.AccountAdapter
import com.h.pixeldroid.objects.Account
import com.h.pixeldroid.objects.Results
/**
* Fragment to show a list of [Account]s, as a result of a search.
*/
class SearchAccountFragment : UncachedFeedFragment<Account>() {
private lateinit var query: String
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
adapter = AccountAdapter()
query = arguments?.getSerializable("searchFeed") as String
}
@ExperimentalPagingApi
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
val view = super.onCreateView(inflater, container, savedInstanceState)
// get the view model
@Suppress("UNCHECKED_CAST")
viewModel = ViewModelProvider(this, ViewModelFactory(
SearchContentRepository<Account>(
apiHolder.setDomainToCurrentUser(db),
Results.SearchType.accounts,
db.userDao().getActiveUser()!!.accessToken,
query
)
)
)
.get(FeedViewModel::class.java) as FeedViewModel<Account>
launch()
initSearch()
return view
}
}

View File

@ -0,0 +1,43 @@
package com.h.pixeldroid.fragments.feeds.uncachedFeeds.search
import androidx.paging.ExperimentalPagingApi
import androidx.paging.Pager
import androidx.paging.PagingConfig
import androidx.paging.PagingData
import com.h.pixeldroid.api.PixelfedAPI
import com.h.pixeldroid.fragments.feeds.uncachedFeeds.UncachedContentRepository
import com.h.pixeldroid.objects.FeedContent
import com.h.pixeldroid.objects.Results
import kotlinx.coroutines.flow.Flow
import javax.inject.Inject
/**
* Repository class to perform searches
*
* The type argument [T] and the [Results.SearchType][type] argument should always
* be in agreement, e.g. if [T] is a [com.h.pixeldroid.objects.Account] then
* [type] should be [Results.SearchType.accounts].
*/
class SearchContentRepository<T: FeedContent> @ExperimentalPagingApi
@Inject constructor(
private val api: PixelfedAPI,
private val type: Results.SearchType,
private val accessToken: String,
private val query: String,
): UncachedContentRepository<T> {
override fun getStream(): Flow<PagingData<T>> {
return Pager(
config = PagingConfig(
initialLoadSize = NETWORK_PAGE_SIZE,
pageSize = NETWORK_PAGE_SIZE,
enablePlaceholders = false),
pagingSourceFactory = {
SearchPagingSource<T>(api, query, type, accessToken)
}
).flow
}
companion object {
private const val NETWORK_PAGE_SIZE = 20
}
}

View File

@ -0,0 +1,133 @@
package com.h.pixeldroid.fragments.feeds.uncachedFeeds.search
import android.annotation.SuppressLint
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.TextView
import androidx.lifecycle.ViewModelProvider
import androidx.paging.ExperimentalPagingApi
import androidx.paging.PagingDataAdapter
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.RecyclerView
import com.h.pixeldroid.R
import com.h.pixeldroid.fragments.feeds.uncachedFeeds.UncachedFeedFragment
import com.h.pixeldroid.fragments.feeds.uncachedFeeds.FeedViewModel
import com.h.pixeldroid.fragments.feeds.uncachedFeeds.ViewModelFactory
import com.h.pixeldroid.objects.Account
import com.h.pixeldroid.objects.Results
import com.h.pixeldroid.objects.Tag
import kotlinx.android.synthetic.main.fragment_tags.view.*
/**
* Fragment to show a list of [hashtag][Tag]s, as a result of a search.
*/
class SearchHashtagFragment : UncachedFeedFragment<Tag>() {
private lateinit var query: String
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
adapter = HashTagAdapter()
query = arguments?.getSerializable("searchFeed") as String
}
@ExperimentalPagingApi
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
val view = super.onCreateView(inflater, container, savedInstanceState)
// get the view model
@Suppress("UNCHECKED_CAST")
viewModel = ViewModelProvider(this, ViewModelFactory(
SearchContentRepository<Tag>(
apiHolder.setDomainToCurrentUser(db),
Results.SearchType.hashtags,
db.userDao().getActiveUser()!!.accessToken,
query
)
)
)
.get(FeedViewModel::class.java) as FeedViewModel<Tag>
launch()
initSearch()
return view
}
}
class HashTagAdapter : PagingDataAdapter<Tag, RecyclerView.ViewHolder>(
UIMODEL_COMPARATOR
) {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
return HashTagViewHolder.create(parent)
}
override fun getItemViewType(position: Int): Int {
return R.layout.fragment_tags
}
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
val uiModel = getItem(position)
uiModel.let {
(holder as HashTagViewHolder).bind(it)
}
}
companion object {
private val UIMODEL_COMPARATOR = object : DiffUtil.ItemCallback<Tag>() {
override fun areItemsTheSame(oldItem: Tag, newItem: Tag): Boolean {
return oldItem.id == newItem.id
}
override fun areContentsTheSame(oldItem: Tag, newItem: Tag): Boolean =
oldItem == newItem
}
}
}
/**
* View Holder for a [Tag] RecyclerView list item.
*/
class HashTagViewHolder(view: View) : RecyclerView.ViewHolder(view) {
private val name : TextView = view.tag_name
private var tag: Tag? = null
init {
itemView.setOnClickListener {
//TODO
}
}
fun bind(tag: Tag?) {
this.tag = tag
@SuppressLint("SetTextI18n")
name.text = "#" + tag?.name
}
companion object {
fun create(parent: ViewGroup): HashTagViewHolder {
val view = LayoutInflater.from(parent.context)
.inflate(R.layout.fragment_tags, parent, false)
return HashTagViewHolder(view)
}
}
}

View File

@ -0,0 +1,47 @@
package com.h.pixeldroid.fragments.feeds.uncachedFeeds.search
import androidx.paging.PagingSource
import com.h.pixeldroid.api.PixelfedAPI
import com.h.pixeldroid.objects.FeedContent
import com.h.pixeldroid.objects.Results
import retrofit2.HttpException
import java.io.IOException
/**
* Provides the PagingSource for search feeds. Is used in [SearchContentRepository]
*/
class SearchPagingSource<T: FeedContent>(
private val api: PixelfedAPI,
private val query: String,
private val type: Results.SearchType,
private val accessToken: String,
) : PagingSource<Int, T>() {
override suspend fun load(params: LoadParams<Int>): LoadResult<Int, T> {
val position = params.key
return try {
val response = api.search(authorization = "Bearer $accessToken",
offset = position?.toString(),
q = query,
type = type,
limit = params.loadSize.toString())
@Suppress("UNCHECKED_CAST")
val repos = when(type){
Results.SearchType.accounts -> response.accounts
Results.SearchType.hashtags -> response.hashtags
Results.SearchType.statuses -> response.statuses
} as List<T>
LoadResult.Page(
data = repos,
prevKey = null,
nextKey = if (repos.isEmpty()) null else (position ?: 0) + repos.size
)
} catch (exception: IOException) {
LoadResult.Error(exception)
} catch (exception: HttpException) {
LoadResult.Error(exception)
}
}
}

View File

@ -0,0 +1,89 @@
package com.h.pixeldroid.fragments.feeds.uncachedFeeds.search
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.lifecycle.ViewModelProvider
import androidx.paging.ExperimentalPagingApi
import androidx.paging.PagingDataAdapter
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.RecyclerView
import com.h.pixeldroid.R
import com.h.pixeldroid.fragments.StatusViewHolder
import com.h.pixeldroid.fragments.feeds.uncachedFeeds.*
import com.h.pixeldroid.objects.Account
import com.h.pixeldroid.objects.Results
import com.h.pixeldroid.objects.Status
/**
* Fragment to show a list of [Status]es, as a result of a search.
*/
class SearchPostsFragment : UncachedFeedFragment<Status>() {
private lateinit var query: String
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
adapter = PostsAdapter()
query = arguments?.getSerializable("searchFeed") as String
}
@ExperimentalPagingApi
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
val view = super.onCreateView(inflater, container, savedInstanceState)
// get the view model
@Suppress("UNCHECKED_CAST")
viewModel = ViewModelProvider(this, ViewModelFactory(
SearchContentRepository<Status>(
apiHolder.setDomainToCurrentUser(db),
Results.SearchType.statuses,
db.userDao().getActiveUser()!!.accessToken,
query
)
)
)
.get(FeedViewModel::class.java) as FeedViewModel<Status>
launch()
initSearch()
return view
}
inner class PostsAdapter : PagingDataAdapter<Status, RecyclerView.ViewHolder>(
object : DiffUtil.ItemCallback<Status>() {
override fun areItemsTheSame(oldItem: Status, newItem: Status): Boolean {
return oldItem.id == newItem.id
}
override fun areContentsTheSame(oldItem: Status, newItem: Status): Boolean =
oldItem.id == newItem.id
}
) {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
return StatusViewHolder.create(parent)
}
override fun getItemViewType(position: Int): Int {
return R.layout.post_fragment
}
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
val uiModel = getItem(position) as Status
uiModel.let {
val instanceUri = db.userDao().getActiveUser()!!.instance_uri
val accessToken = db.userDao().getActiveUser()!!.accessToken
(holder as StatusViewHolder).bind(it, instanceUri, apiHolder.setDomain(instanceUri), "Bearer $accessToken")
}
}
}
}

View File

@ -41,8 +41,8 @@ data class Account(
val moved: Account? = null,
val fields: List<Field>? = emptyList(),
val bot: Boolean? = false,
val source: Source? = null
) : Serializable, FeedContent() {
val source: Source? = null,
) : Serializable, FeedContent {
companion object {
const val ACCOUNT_TAG = "AccountTag"
const val ACCOUNT_ID_TAG = "AccountIdTag"

View File

@ -1,10 +1,13 @@
package com.h.pixeldroid.objects
abstract class FeedContent {
abstract val id: String?
interface FeedContent {
val id: String?
}
override fun hashCode(): Int {
return id.hashCode()
}
interface FeedContentDatabase {
val id: String?
var user_id: String
var instance_uri: String
}

View File

@ -1,11 +1,28 @@
package com.h.pixeldroid.objects
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.Index
import com.h.pixeldroid.db.entities.UserDatabaseEntity
import java.io.Serializable
import java.util.Date
/*
Represents a notification of an event relevant to the user.
https://docs.joinmastodon.org/entities/notification/
*/
@Entity(
tableName = "notifications",
primaryKeys = ["id", "user_id", "instance_uri"],
foreignKeys = [ForeignKey(
entity = UserDatabaseEntity::class,
parentColumns = arrayOf("user_id", "instance_uri"),
childColumns = arrayOf("user_id", "instance_uri"),
onUpdate = ForeignKey.CASCADE,
onDelete = ForeignKey.CASCADE
)],
indices = [Index(value = ["user_id", "instance_uri"])]
)
data class Notification(
//Required attributes
override val id: String,
@ -13,9 +30,14 @@ data class Notification(
val created_at: Date, //ISO 8601 Datetime
val account: Account,
//Optional attributes
val status: Status? = null
): FeedContent() {
enum class NotificationType {
val status: Status? = null,
//Database values (not from API)
//TODO do we find this approach acceptable? Preferable to a semi-duplicate NotificationDataBaseEntity?
override var user_id: String,
override var instance_uri: String,
): FeedContent, FeedContentDatabase {
enum class NotificationType: Serializable {
follow, mention, reblog, favourite, poll
}
}

View File

@ -1,63 +1,43 @@
package com.h.pixeldroid.objects
import android.Manifest
import android.app.DownloadManager
import android.content.Context
import android.content.Intent
import android.database.Cursor
import android.graphics.ColorMatrixColorFilter
import android.graphics.Typeface
import android.graphics.drawable.Drawable
import android.net.Uri
import android.os.Environment
import android.text.Spanned
import android.text.method.LinkMovementMethod
import android.view.View
import android.view.View.GONE
import android.view.View.VISIBLE
import android.widget.*
import androidx.core.content.ContextCompat.startActivity
import android.widget.ImageView
import android.widget.TextView
import androidx.core.net.toUri
import androidx.fragment.app.Fragment
import androidx.viewpager2.adapter.FragmentStateAdapter
import com.bumptech.glide.RequestBuilder
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.Index
import com.google.android.material.snackbar.Snackbar
import com.google.android.material.tabs.TabLayoutMediator
import com.h.pixeldroid.R
import com.h.pixeldroid.ReportActivity
import com.h.pixeldroid.api.PixelfedAPI
import com.h.pixeldroid.fragments.ImageFragment
import com.h.pixeldroid.fragments.feeds.postFeeds.PostViewHolder
import com.h.pixeldroid.db.entities.UserDatabaseEntity
import com.h.pixeldroid.utils.HtmlUtils.Companion.getDomain
import com.h.pixeldroid.utils.HtmlUtils.Companion.parseHTMLText
import com.h.pixeldroid.utils.ImageConverter
import com.h.pixeldroid.utils.PostUtils.Companion.censorColorMatrix
import com.h.pixeldroid.utils.PostUtils.Companion.likePostCall
import com.h.pixeldroid.utils.PostUtils.Companion.postComment
import com.h.pixeldroid.utils.PostUtils.Companion.reblogPost
import com.h.pixeldroid.utils.PostUtils.Companion.retrieveComments
import com.h.pixeldroid.utils.PostUtils.Companion.toggleCommentInput
import com.h.pixeldroid.utils.PostUtils.Companion.unLikePostCall
import com.h.pixeldroid.utils.PostUtils.Companion.uncensorColorMatrix
import com.h.pixeldroid.utils.PostUtils.Companion.undoReblogPost
import com.h.pixeldroid.utils.Utils.Companion.setTextViewFromISO8601
import com.karumi.dexter.Dexter
import com.karumi.dexter.listener.PermissionDeniedResponse
import com.karumi.dexter.listener.PermissionGrantedResponse
import com.karumi.dexter.listener.single.BasePermissionListener
import kotlinx.android.synthetic.main.post_fragment.view.*
import java.io.File
import java.io.Serializable
import java.util.*
import kotlin.collections.ArrayList
/*
Represents a status posted by an account.
https://docs.joinmastodon.org/entities/status/
/**
Represents a status posted by an account.
https://docs.joinmastodon.org/entities/status/
*/
data class Status(
open class Status(
//Base attributes
override val id: String?,
override val id: String,
val uri: String? = "",
val created_at: Date? = Date(0), //ISO 8601 Datetime
val account: Account?,
@ -89,10 +69,9 @@ data class Status(
val reblogged: Boolean? = false,
val muted: Boolean? = false,
val bookmarked: Boolean? = false,
val pinned: Boolean? = false
) : Serializable, FeedContent()
val pinned: Boolean? = false,
) : Serializable, FeedContent
{
companion object {
const val POST_TAG = "postTag"
const val DOMAIN_TAG = "domainTag"
@ -117,45 +96,14 @@ data class Status(
return context.getString(R.string.shares).format(reblogs_count.toString())
}
private fun getStatusDomain(domain: String) : String {
fun getStatusDomain(domain: String) : String {
val accountDomain = getDomain(account!!.url)
return if(getDomain(domain) == accountDomain) ""
else " from $accountDomain"
}
private fun setupPostPics(
rootView: View,
request: RequestBuilder<Drawable>,
homeFragment: Fragment
) {
// Standard layout
rootView.postPicture.visibility = VISIBLE
rootView.postPager.visibility = GONE
rootView.postTabs.visibility = GONE
if(media_attachments?.size == 1) {
request.load(this.getPostUrl()).into(rootView.postPicture)
val imgDescription = media_attachments[0].description.orEmpty().ifEmpty { rootView.context.getString(R.string.no_description) }
rootView.postPicture.contentDescription = imgDescription
rootView.postPicture.setOnLongClickListener {
Snackbar.make(it, imgDescription, Snackbar.LENGTH_SHORT).show()
true
}
} else if(media_attachments?.size!! > 1) {
setupTabsLayout(rootView, request, homeFragment)
}
if (sensitive!!) {
setupSensitiveLayout(rootView)
}
}
private fun setupSensitiveLayout(view: View) {
fun setupSensitiveLayout(view: View) {
// Set dark layout and warning message
view.sensitiveWarning.visibility = VISIBLE
@ -176,161 +124,6 @@ data class Status(
}
}
private fun setupTabsLayout(
rootView: View,
request: RequestBuilder<Drawable>,
homeFragment: Fragment
) {
//Only show the viewPager and tabs
rootView.postPicture.visibility = GONE
rootView.postPager.visibility = VISIBLE
rootView.postTabs.visibility = VISIBLE
val tabs : ArrayList<ImageFragment> = ArrayList()
//Fill the tabs with each mediaAttachment
for(media in media_attachments!!) {
tabs.add(ImageFragment.newInstance(media.url!!, media.description.orEmpty()))
}
setupTabs(tabs, rootView, homeFragment)
}
private fun setupTabs(tabs: ArrayList<ImageFragment>, rootView: View, homeFragment: Fragment) {
//Attach the given tabs to the view pager
rootView.postPager.adapter = object : FragmentStateAdapter(homeFragment) {
override fun createFragment(position: Int): Fragment {
return tabs[position]
}
override fun getItemCount(): Int {
return media_attachments?.size ?: 0
}
}
TabLayoutMediator(rootView.postTabs, rootView.postPager) { tab, _ ->
tab.icon = rootView.context.getDrawable(R.drawable.ic_dot_blue_12dp)
}.attach()
}
fun setupPost(
rootView: View,
request: RequestBuilder<Drawable>,
homeFragment: Fragment,
domain: String,
isActivity: Boolean
) {
//Setup username as a button that opens the profile
rootView.findViewById<TextView>(R.id.username).apply {
text = this@Status.account?.getDisplayName() ?: ""
setTypeface(null, Typeface.BOLD)
setOnClickListener { account?.openProfile(rootView.context) }
}
rootView.findViewById<TextView>(R.id.usernameDesc).apply {
text = this@Status.account?.getDisplayName() ?: ""
setTypeface(null, Typeface.BOLD)
}
rootView.findViewById<TextView>(R.id.nlikes).apply {
text = this@Status.getNLikes(rootView.context)
setTypeface(null, Typeface.BOLD)
}
rootView.findViewById<TextView>(R.id.nshares).apply {
text = this@Status.getNShares(rootView.context)
setTypeface(null, Typeface.BOLD)
}
//Convert the date to a readable string
setTextViewFromISO8601(created_at!!, rootView.postDate, isActivity, rootView.context)
rootView.postDomain.text = getStatusDomain(domain)
//Setup images
ImageConverter.setRoundImageFromURL(
rootView,
this.getProfilePicUrl(),
rootView.profilePic
)
rootView.profilePic.setOnClickListener { account?.openProfile(rootView.context) }
//Setup post pic only if there are media attachments
if(!media_attachments.isNullOrEmpty()) {
setupPostPics(rootView, request, homeFragment)
} else {
rootView.postPicture.visibility = GONE
rootView.postPager.visibility = GONE
rootView.postTabs.visibility = GONE
}
//Set comment initial visibility
rootView.findViewById<LinearLayout>(R.id.commentIn).visibility = GONE
rootView.findViewById<LinearLayout>(R.id.commentContainer).visibility = GONE
}
fun setDescription(rootView: View, api: PixelfedAPI, credential: String) {
rootView.findViewById<TextView>(R.id.description).apply {
if (content.isNullOrBlank()) {
visibility = GONE
} else {
text = parseHTMLText(content, mentions, api, rootView.context, credential)
movementMethod = LinkMovementMethod.getInstance()
}
}
}
fun activateButtons(holder: PostViewHolder, api: PixelfedAPI, credential: String){
//Set the special HTML text
setDescription(holder.postView, api, credential)
//Activate onclickListeners
activateLiker(
holder, api, credential,
this.favourited ?: false
)
activateReblogger(
holder, api, credential,
this.reblogged ?: false
)
activateCommenter(holder, api, credential)
showComments(holder, api, credential)
//Activate double tap liking
activateDoubleTapLiker(holder, api, credential)
activateMoreButton(holder)
}
fun activateReblogger(
holder: PostViewHolder,
api: PixelfedAPI,
credential: String,
isReblogged: Boolean
) {
holder.reblogger.apply {
//Set initial button state
isChecked = isReblogged
//Activate the button
setEventListener { _, buttonState ->
if (buttonState) {
// Button is active
undoReblogPost(holder, api, credential, this@Status)
} else {
// Button is inactive
reblogPost(holder, api, credential, this@Status)
}
//show animation or not?
true
}
}
}
fun downloadImage(context: Context, url: String, view: View, share: Boolean = false) {
val downloadManager = context.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager
@ -403,199 +196,7 @@ data class Status(
}.start()
}
fun activateMoreButton(holder: PostViewHolder){
holder.more.setOnClickListener {
PopupMenu(it.context, it).apply {
setOnMenuItemClickListener { item ->
when (item.itemId) {
R.id.post_more_menu_report -> {
val intent = Intent(it.context, ReportActivity::class.java)
intent.putExtra(POST_TAG, this@Status)
startActivity(it.context, intent, null)
true
}
R.id.post_more_menu_share_link -> {
val share = Intent.createChooser(Intent().apply {
action = Intent.ACTION_SEND
putExtra(Intent.EXTRA_TEXT, uri)
type = "text/plain"
putExtra(Intent.EXTRA_TITLE, content)
}, null)
startActivity(it.context, share, null)
true
}
R.id.post_more_menu_save_to_gallery -> {
Dexter.withContext(holder.context)
.withPermission(Manifest.permission.WRITE_EXTERNAL_STORAGE)
.withListener(object : BasePermissionListener() {
override fun onPermissionDenied(p0: PermissionDeniedResponse?) {
Toast.makeText(
holder.context,
holder.context.getString(R.string.write_permission_download_pic),
Toast.LENGTH_SHORT
).show()
}
override fun onPermissionGranted(p0: PermissionGrantedResponse?) {
downloadImage(
holder.context,
media_attachments?.get(holder.postPager.currentItem)?.url
?: "",
holder.postView
)
}
}).check()
true
}
R.id.post_more_menu_share_picture -> {
Dexter.withContext(holder.context)
.withPermission(Manifest.permission.WRITE_EXTERNAL_STORAGE)
.withListener(object : BasePermissionListener() {
override fun onPermissionDenied(p0: PermissionDeniedResponse?) {
Toast.makeText(
holder.context,
holder.context.getString(R.string.write_permission_share_pic),
Toast.LENGTH_SHORT
).show()
}
override fun onPermissionGranted(p0: PermissionGrantedResponse?) {
downloadImage(
holder.context,
media_attachments?.get(holder.postPager.currentItem)?.url
?: "",
holder.postView,
share = true,
)
}
}).check()
true
}
else -> false
}
}
inflate(R.menu.post_more_menu)
if(media_attachments.isNullOrEmpty()) {
//make sure to disable image-related things if there aren't any
menu.setGroupVisible(R.id.post_more_group_picture, false)
}
show()
}
}
}
fun activateDoubleTapLiker(
holder: PostViewHolder,
api: PixelfedAPI,
credential: String
) {
holder.apply {
var clicked = false
postPic.setOnClickListener {
//Check that the post isn't hidden
if(sensitiveW.visibility == GONE) {
//Check for double click
if(clicked) {
if (holder.liker.isChecked) {
// Button is active, unlike
holder.liker.isChecked = false
unLikePostCall(holder, api, credential, this@Status)
} else {
// Button is inactive, like
holder.liker.playAnimation()
holder.liker.isChecked = true
likePostCall(holder, api, credential, this@Status)
}
} else {
clicked = true
//Reset clicked to false after 500ms
postPic.handler.postDelayed(fun() { clicked = false }, 500)
}
}
}
}
}
fun activateLiker(
holder: PostViewHolder,
api: PixelfedAPI,
credential: String,
isLiked: Boolean
) {
holder.liker.apply {
//Set initial state
isChecked = isLiked
//Activate the liker
setEventListener { _, buttonState ->
if (buttonState) {
// Button is active, unlike
unLikePostCall(holder, api, credential, this@Status)
} else {
// Button is inactive, like
likePostCall(holder, api, credential, this@Status)
}
//show animation or not?
true
}
}
}
fun showComments(
holder: PostViewHolder,
api: PixelfedAPI,
credential: String
) {
//Show all comments of a post
if (replies_count == 0) {
holder.viewComment.text = holder.context.getString(R.string.NoCommentsToShow)
} else {
holder.viewComment.apply {
text = "$replies_count ${holder.context.getString(R.string.CommentDisplay)}"
setOnClickListener {
visibility = GONE
//Retrieve the comments
retrieveComments(holder, api, credential, this@Status)
}
}
}
}
fun activateCommenter(
holder: PostViewHolder,
api: PixelfedAPI,
credential: String
) {
//Toggle comment button
toggleCommentInput(holder)
//Activate commenterpostPicture
holder.submitCmnt.setOnClickListener {
val textIn = holder.comment.text
//Open text input
if(textIn.isNullOrEmpty()) {
Toast.makeText(
holder.context,
holder.context.getString(R.string.empty_comment),
Toast.LENGTH_SHORT
).show()
} else {
//Post the comment
postComment(holder, api, credential, this)
}
}
}
enum class Visibility : Serializable {
enum class Visibility: Serializable {
public, unlisted, private, direct
}
}

View File

@ -7,10 +7,9 @@ data class Tag(
val name: String,
val url: String,
//Optional attributes
val history: List<History>? = emptyList()) : Serializable, FeedContent() {
val history: List<History>? = emptyList()) : Serializable, FeedContent {
//needed to be a FeedContent, this inheritance is a bit fickle. Do not use.
override val id: String
get() = "tag"
}

View File

@ -1,14 +1,11 @@
package com.h.pixeldroid.utils
import com.h.pixeldroid.db.AppDatabase
import com.h.pixeldroid.db.InstanceDatabaseEntity
import com.h.pixeldroid.db.PostDatabaseEntity
import com.h.pixeldroid.db.UserDatabaseEntity
import com.h.pixeldroid.db.entities.InstanceDatabaseEntity
import com.h.pixeldroid.db.entities.UserDatabaseEntity
import com.h.pixeldroid.objects.Account
import com.h.pixeldroid.objects.Instance
import com.h.pixeldroid.objects.Status
import com.h.pixeldroid.utils.Utils.Companion.normalizeDomain
import java.util.Date
class DBUtils {
companion object {
@ -48,40 +45,5 @@ class DBUtils {
)
db.instanceDao().insertInstance(dbInstance)
}
fun storePosts(
db: AppDatabase,
data: List<*>,
user: UserDatabaseEntity
) {
val dao = db.postDao()
data.forEach { post ->
if (post is Status
&& !post.media_attachments.isNullOrEmpty()
&& dao.count(post.uri ?: "", user.user_id, user.instance_uri) == 0) {
val nPosts = dao.numberOfPosts(user.user_id, user.instance_uri) - MAX_NUMBER_OF_STORED_POSTS
if (nPosts > 0) {
dao.removeOlderPosts(nPosts)
}
dao.insertPost(PostDatabaseEntity(
user_id = user.user_id,
instance_uri = user.instance_uri,
uri = post.uri ?: "",
account_profile_picture = post.getProfilePicUrl() ?: "",
account_name = (post.account?.getDisplayName() ?: "").toString(),
media_urls = post.media_attachments.map {
attachment -> attachment.url ?: ""
},
favourite_count = post.favourites_count ?: 0,
reply_count = post.replies_count ?: 0,
share_count = post.reblogs_count ?: 0,
description = post.content ?: "",
date = post.created_at ?: Date(0),
likes = post.favourites_count ?: 0,
shares = post.reblogs_count ?: 0
))
}
}
}
}
}

View File

@ -1,240 +1,9 @@
package com.h.pixeldroid.utils
import android.graphics.ColorMatrix
import android.util.Log
import android.view.LayoutInflater
import android.view.View
import android.widget.LinearLayout
import android.widget.Toast
import com.h.pixeldroid.R
import com.h.pixeldroid.api.PixelfedAPI
import com.h.pixeldroid.fragments.feeds.postFeeds.PostViewHolder
import com.h.pixeldroid.objects.Context
import com.h.pixeldroid.objects.Status
import com.h.pixeldroid.utils.ImageConverter.Companion.setImageFromDrawable
import kotlinx.android.synthetic.main.comment.view.*
import retrofit2.Call
import retrofit2.Callback
import retrofit2.Response
abstract class PostUtils {
companion object {
fun toggleCommentInput(
holder : PostViewHolder
) {
//Toggle comment button
holder.commenter.setOnClickListener {
when(holder.commentIn.visibility) {
View.VISIBLE -> {
holder.commentIn.visibility = View.GONE
setImageFromDrawable(holder.postView, holder.commenter, R.drawable.ic_comment_empty)
}
View.GONE -> {
holder.commentIn.visibility = View.VISIBLE
setImageFromDrawable(holder.postView, holder.commenter, R.drawable.ic_comment_blue)
}
}
}
}
fun reblogPost(
holder : PostViewHolder,
api: PixelfedAPI,
credential: String,
post : Status
) {
//Call the api function
api.reblogStatus(credential, post.id!!).enqueue(object : Callback<Status> {
override fun onFailure(call: Call<Status>, t: Throwable) {
Log.e("REBLOG ERROR", t.toString())
holder.reblogger.isChecked = false
}
override fun onResponse(call: Call<Status>, response: Response<Status>) {
if(response.code() == 200) {
val resp = response.body()!!
//Update shown share count
holder.nshares.text = resp.getNShares(holder.context)
holder.reblogger.isChecked = resp.reblogged!!
} else {
Log.e("RESPONSE_CODE", response.code().toString())
holder.reblogger.isChecked = false
}
}
})
}
fun undoReblogPost(
holder : PostViewHolder,
api: PixelfedAPI,
credential: String,
post : Status
) {
//Call the api function
api.undoReblogStatus(credential, post.id!!).enqueue(object : Callback<Status> {
override fun onFailure(call: Call<Status>, t: Throwable) {
Log.e("REBLOG ERROR", t.toString())
holder.reblogger.isChecked = true
}
override fun onResponse(call: Call<Status>, response: Response<Status>) {
if(response.code() == 200) {
val resp = response.body()!!
//Update shown share count
holder.nshares.text = resp.getNShares(holder.context)
holder.reblogger.isChecked = resp.reblogged!!
} else {
Log.e("RESPONSE_CODE", response.code().toString())
holder.reblogger.isChecked = true
}
}
})
}
fun likePostCall(
holder : PostViewHolder,
api: PixelfedAPI,
credential: String,
post : Status
) {
//Call the api function
api.likePost(credential, post.id!!).enqueue(object : Callback<Status> {
override fun onFailure(call: Call<Status>, t: Throwable) {
Log.e("LIKE ERROR", t.toString())
holder.liker.isChecked = false
}
override fun onResponse(call: Call<Status>, response: Response<Status>) {
if(response.code() == 200) {
val resp = response.body()!!
//Update shown like count and internal like toggle
holder.nlikes.text = resp.getNLikes(holder.context)
holder.liker.isChecked = resp.favourited ?: false
} else {
Log.e("RESPONSE_CODE", response.code().toString())
holder.liker.isChecked = false
}
}
})
}
fun unLikePostCall(
holder : PostViewHolder,
api: PixelfedAPI,
credential: String,
post : Status
) {
//Call the api function
api.unlikePost(credential, post.id!!).enqueue(object : Callback<Status> {
override fun onFailure(call: Call<Status>, t: Throwable) {
Log.e("UNLIKE ERROR", t.toString())
holder.liker.isChecked = true
}
override fun onResponse(call: Call<Status>, response: Response<Status>) {
if(response.code() == 200) {
val resp = response.body()!!
//Update shown like count and internal like toggle
holder.nlikes.text = resp.getNLikes(holder.context)
holder.liker.isChecked = resp.favourited ?: false
} else {
Log.e("RESPONSE_CODE", response.code().toString())
holder.liker.isChecked = true
}
}
})
}
fun postComment(
holder : PostViewHolder,
api: PixelfedAPI,
credential: String,
post : Status
) {
val textIn = holder.comment.text
val nonNullText = textIn.toString()
api.postStatus(credential, nonNullText, post.id).enqueue(object :
Callback<Status> {
override fun onFailure(call: Call<Status>, t: Throwable) {
Log.e("COMMENT ERROR", t.toString())
Toast.makeText(holder.context, holder.context.getString(R.string.comment_error),
Toast.LENGTH_SHORT).show()
}
override fun onResponse(call: Call<Status>, response: Response<Status>) {
//Check that the received response code is valid
if(response.code() == 200) {
val resp = response.body()!!
holder.commentIn.visibility = View.GONE
//Add the comment to the comment section
addComment(holder.context, holder.commentCont, resp.account!!.username!!,
resp.content!!
)
Toast.makeText(holder.context,
holder.context.getString(R.string.comment_posted).format(textIn),
Toast.LENGTH_SHORT).show()
Log.e("COMMENT SUCCESS", "posted: $textIn")
} else {
Log.e("ERROR_CODE", response.code().toString())
}
}
})
}
fun addComment(context: android.content.Context, commentContainer: LinearLayout, commentUsername: String, commentContent: String) {
val view = LayoutInflater.from(context)
.inflate(R.layout.comment, commentContainer, true)
view.user.text = commentUsername
view.commentText.text = commentContent
}
fun retrieveComments(
holder : PostViewHolder,
api: PixelfedAPI,
credential: String,
post : Status
) {
api.statusComments(post.id!!, credential).enqueue(object :
Callback<Context> {
override fun onFailure(call: Call<Context>, t: Throwable) {
Log.e("COMMENT FETCH ERROR", t.toString())
}
override fun onResponse(
call: Call<Context>,
response: Response<Context>
) {
if(response.code() == 200) {
val statuses = response.body()!!.descendants
holder.commentCont.removeAllViews()
//Create the new views for each comment
for (status in statuses) {
addComment(holder.context, holder.commentCont, status.account!!.username!!,
status.content!!
)
}
holder.commentCont.visibility = View.VISIBLE
} else {
Log.e("COMMENT ERROR", "${response.code()} with body ${response.errorBody()}")
}
}
})
}
fun censorColorMatrix(): ColorMatrix {
val array: FloatArray = floatArrayOf( 0f, 0f, 0f, 0f, 0f, 0f, 0f, 0f, 0f, 0f, 0f, 0f, 0f, 0f, 0f, 0f, 0f, 0f, 1f, 0f )

View File

@ -20,6 +20,7 @@
<ScrollView
android:layout_width="match_parent"
android:layout_height="match_parent">
<androidx.fragment.app.FragmentContainerView
android:id="@+id/postFragmentSingle"
android:layout_width="match_parent"
@ -28,8 +29,9 @@
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" >
</androidx.fragment.app.FragmentContainerView>
app:layout_constraintTop_toTopOf="parent"
tools:context=".fragments.PostFragment"
tools:visibility="visible"/>
</ScrollView>
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -1,9 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".fragments.ImageFragment">
android:layout_height="match_parent">
<ImageView
@ -13,5 +13,4 @@
android:adjustViewBounds="true"
tools:ignore="ContentDescription" />
</FrameLayout>

View File

@ -6,6 +6,17 @@
android:layout_width="match_parent"
android:layout_height="match_parent">
<ProgressBar
android:id="@+id/progressBar"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:visibility="visible"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
android:id="@+id/swipeRefreshLayout"
android:layout_width="wrap_content"
@ -16,23 +27,36 @@
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/list"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:layoutManager="LinearLayoutManager" />
<androidx.constraintlayout.motion.widget.MotionLayout
android:id="@+id/motionLayout"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_marginTop="8dp"
android:visibility="visible"
app:layoutDescription="@xml/error_layout_xml_error_scene"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/followButton"
tools:visibility="visible">
<include
android:id="@id/errorLayout"
layout="@layout/error_layout"
tools:layout_editor_absoluteX="50dp" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/list"
android:visibility="visible"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:layout_constraintTop_toBottomOf="@id/errorLayout"
app:layoutManager="LinearLayoutManager"
tools:listitem="@layout/post_fragment"/>
</androidx.constraintlayout.motion.widget.MotionLayout>
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
<ProgressBar
android:id="@+id/progressBar"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<include layout="@layout/error_layout"/>
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -0,0 +1,45 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
~ Copyright (C) 2018 The Android Open Source Project
~
~ Licensed under the Apache License, Version 2.0 (the "License");
~ you may not use this file except in compliance with the License.
~ You may obtain a copy of the License at
~
~ http://www.apache.org/licenses/LICENSE-2.0
~
~ Unless required by applicable law or agreed to in writing, software
~ distributed under the License is distributed on an "AS IS" BASIS,
~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
~ See the License for the specific language governing permissions and
~ limitations under the License.
-->
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
xmlns:tools="http://schemas.android.com/tools"
android:orientation="vertical"
android:padding="8dp">
<TextView
android:id="@+id/error_msg"
android:textColor="?android:textColorPrimary"
android:textSize="24sp"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:textAlignment="center"
tools:text="Timeout"/>
<ProgressBar
android:id="@+id/progress_bar"
style="?android:attr/progressBarStyle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="center"/>
<Button
android:id="@+id/retry_button"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:text="@string/retry"/>
</LinearLayout>

File diff suppressed because one or more lines are too long

View File

@ -37,10 +37,10 @@ class PostUnitTest {
@Test
fun getUsernameReturnsACorrectName() = Assert.assertEquals(status.account!!.display_name, status.account!!.getDisplayName())
@Test
/*@Test
fun getUsernameReturnsOtherNameIfUsernameIsNull() {
val emptyDescStatus = status.copy(account = status.account!!.copy(username = ""))
Assert.assertEquals(status.account!!.display_name, emptyDescStatus.account!!.getDisplayName())
}
}*/
}

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.4.10'
ext.kotlin_version = '1.4.20'
repositories {
google()
jcenter()
}
dependencies {
classpath 'com.android.tools.build:gradle:4.1.0'
classpath 'com.android.tools.build:gradle:4.1.1'
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
// NOTE: Do not place your application dependencies here; they belong

View File

@ -22,3 +22,4 @@ kotlin.code.style=official
org.gradle.daemon=true
org.gradle.caching=true
org.gradle.parallel=true
kapt.incremental.apt=true