Reblogging and HTML text (#107)

* Changed Post interaction icons and added click feedback

* added reblog and unreblog api implementations

* Use fancy animated buttons

* WIP reposter

* WIP reblog button

* renamed ViewHolder => PostViewHolder

* activated reblogger in feed

* added custom html parser, still need to fix clickable links

* added parsed HTML in notifications

* fixed mention click

* added tests for reblog and clickable mentions

* adapted unit tests to work with new html parser

* changed incoherent comment

* made hashtags slightly less useless

* removed unit test that were no longer valid

* removed useless test

* trying to fix tests

* fixing tests

* trying to improve coverage a little

* removed unused code to improve coverage

* changed cast to type converter

* added failure responses to help coverage

* added mock server response for reblogging

* fixed broken json

* trying to fix a broken test

* Tweak tests

* Typo in test

* Add scrolls to make tests pass on small screens

* fixed old JSON in mockserver

* fixed linter issue

Co-authored-by: Matthieu <61561059+Wv5twkFEKh54vo4tta9yu7dHa3@users.noreply.github.com>
This commit is contained in:
Andrew Dobis 2020-04-23 17:48:45 +02:00 committed by GitHub
parent 8950d4c162
commit 8265ac2d62
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
29 changed files with 1142 additions and 474 deletions

View File

@ -64,6 +64,7 @@ dependencies {
implementation 'io.reactivex.rxjava2:rxandroid:2.1.1'
implementation "androidx.browser:browser:1.2.0"
implementation 'com.google.android.material:material:1.1.0'
implementation 'com.github.connyduck:sparkbutton:3.0.0'
def room_version = "2.2.5"
implementation "androidx.room:room-runtime:$room_version"

View File

@ -2,19 +2,39 @@ package com.h.pixeldroid
import android.content.Context
import android.content.Intent
import android.text.SpannableString
import android.text.style.ClickableSpan
import android.view.View
import android.widget.TextView
import androidx.test.core.app.ActivityScenario
import androidx.test.espresso.Espresso
import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.NoMatchingViewException
import androidx.test.espresso.UiController
import androidx.test.espresso.ViewAction
import androidx.test.espresso.action.ViewActions
import androidx.test.espresso.assertion.ViewAssertions
import androidx.test.espresso.contrib.RecyclerViewActions
import androidx.test.espresso.intent.Intents
import androidx.test.espresso.intent.Intents.intended
import androidx.test.espresso.intent.matcher.BundleMatchers.hasValue
import androidx.test.espresso.intent.matcher.IntentMatchers
import androidx.test.espresso.intent.rule.IntentsTestRule
import androidx.test.espresso.matcher.ViewMatchers
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.rule.ActivityTestRule
import com.google.android.material.tabs.TabLayout
import com.h.pixeldroid.fragments.feeds.PostViewHolder
import com.h.pixeldroid.objects.Account
import com.h.pixeldroid.objects.Account.Companion.ACCOUNT_TAG
import com.h.pixeldroid.testUtility.CustomMatchers
import com.h.pixeldroid.testUtility.CustomMatchers.Companion.first
import com.h.pixeldroid.testUtility.MockServer
import org.hamcrest.CoreMatchers
import org.hamcrest.Matcher
import org.hamcrest.Matchers
import org.junit.After
import org.junit.Before
import org.junit.Rule
import org.junit.Test
@ -32,7 +52,7 @@ class IntentTest {
@get:Rule
var mLoginActivityActivityTestRule =
IntentsTestRule(
ActivityTestRule(
LoginActivity::class.java
)
@ -44,8 +64,87 @@ class IntentTest {
.targetContext.getSharedPreferences("com.h.pixeldroid.pref", Context.MODE_PRIVATE)
preferences.edit().putString("accessToken", "azerty").apply()
preferences.edit().putString("domain", baseUrl.toString()).apply()
Intents.init()
}
@Test
fun clickingMentionOpensProfile() {
ActivityScenario.launch(MainActivity::class.java)
val account = Account("1450", "deerbard_photo", "deerbard_photo",
"https://pixelfed.social/deerbard_photo", "deerbard photography",
"",
"https://pixelfed.social/storage/avatars/000/000/001/450/SMSep5NoabDam1W8UDMh_avatar.png?v=4b227777d4dd1fc61c6f884f48641d02b4d121d3fd328cb08b5531fcacdabf8a",
"https://pixelfed.social/storage/avatars/000/000/001/450/SMSep5NoabDam1W8UDMh_avatar.png?v=4b227777d4dd1fc61c6f884f48641d02b4d121d3fd328cb08b5531fcacdabf8a",
"", "", false, emptyList(), false,
"2018-08-01T12:58:21.000000Z", 72, 68, 27,
null, null, false, null)
val expectedIntent: Matcher<Intent> = CoreMatchers.allOf(
IntentMatchers.hasExtra(ACCOUNT_TAG, account)
)
Thread.sleep(1000)
//Click the mention
Espresso.onView(ViewMatchers.withId(R.id.list))
.perform(RecyclerViewActions.actionOnItemAtPosition<PostViewHolder>
(0, clickClickableSpanInDescription("@Dobios")))
//Wait a bit
Thread.sleep(1000)
//Check that the Profile is shown
intended(expectedIntent)
}
fun clickClickableSpanInDescription(textToClick: CharSequence): ViewAction {
return object : ViewAction {
override fun getConstraints(): Matcher<View> {
return Matchers.instanceOf(TextView::class.java)
}
override fun getDescription(): String {
return "clicking on a ClickableSpan";
}
override fun perform(uiController: UiController, view: View) {
val textView = view.findViewById<View>(R.id.description) as TextView
val spannableString = textView.text as SpannableString
if (spannableString.isEmpty()) {
// TextView is empty, nothing to do
throw NoMatchingViewException.Builder()
.includeViewHierarchy(true)
.withRootView(textView)
.build();
}
// Get the links inside the TextView and check if we find textToClick
val spans = spannableString.getSpans(0, spannableString.length, ClickableSpan::class.java)
if (spans.isNotEmpty()) {
var spanCandidate: ClickableSpan
for (span: ClickableSpan in spans) {
spanCandidate = span
val start = spannableString.getSpanStart(spanCandidate)
val end = spannableString.getSpanEnd(spanCandidate)
val sequence = spannableString.subSequence(start, end)
if (textToClick.toString().equals(sequence.toString())) {
span.onClick(textView)
return;
}
}
}
// textToClick not found in TextView
throw NoMatchingViewException.Builder()
.includeViewHierarchy(true)
.withRootView(textView)
.build()
}
}
}
@Test
fun launchesIntent() {
@ -63,7 +162,10 @@ class IntentTest {
Thread.sleep(1000)
intended(expectedIntent)
}
@After
fun after() {
Intents.release()
}
}

View File

@ -5,10 +5,13 @@ import android.content.Context
import android.content.Intent
import android.content.Intent.ACTION_VIEW
import android.net.Uri
import androidx.test.core.app.ActivityScenario
import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.action.ViewActions
import androidx.test.espresso.action.ViewActions.click
import androidx.test.espresso.action.ViewActions.scrollTo
import androidx.test.espresso.assertion.ViewAssertions.matches
import androidx.test.espresso.intent.Intents
import androidx.test.espresso.intent.Intents.intended
import androidx.test.espresso.intent.matcher.IntentMatchers.hasAction
import androidx.test.espresso.intent.matcher.IntentMatchers.hasDataString
@ -24,6 +27,7 @@ import org.hamcrest.CoreMatchers.allOf
import org.hamcrest.CoreMatchers.anyOf
import org.hamcrest.CoreMatchers.containsString
import org.hamcrest.Matcher
import org.junit.After
import org.junit.Before
import org.junit.Rule
import org.junit.Test
@ -52,14 +56,14 @@ class LoginInstrumentedTest {
@Test
fun invalidURL() {
onView(withId(R.id.editText)).perform(ViewActions.replaceText("/jdi"), ViewActions.closeSoftKeyboard())
onView(withId(R.id.connect_instance_button)).perform(click())
onView(withId(R.id.connect_instance_button)).perform(scrollTo()).perform(click())
onView(withId(R.id.editText)).check(matches(hasErrorText("Invalid domain")))
}
@Test
fun notPixelfedInstance() {
onView(withId(R.id.editText)).perform(ViewActions.replaceText("localhost"), ViewActions.closeSoftKeyboard())
onView(withId(R.id.connect_instance_button)).perform(click())
onView(withId(R.id.connect_instance_button)).perform(scrollTo()).perform(click())
onView(withId(R.id.editText)).check(matches(hasErrorText("Could not register the application with this server")))
}
}
@ -69,37 +73,50 @@ class LoginCheckIntent {
@get:Rule
var globalTimeout: Timeout = Timeout.seconds(100)
@get:Rule
val intentsTestRule = IntentsTestRule(LoginActivity::class.java)
val intentsTestRule = ActivityTestRule(LoginActivity::class.java)
@Before
fun before() {
Intents.init()
}
@Test
fun launchesOAuthIntent() {
ActivityScenario.launch(LoginActivity::class.java)
val expectedIntent: Matcher<Intent> = allOf(
hasAction(ACTION_VIEW),
hasDataString(containsString("pixelfed.social"))
)
Thread.sleep(1000)
onView(withId(R.id.editText)).perform(ViewActions.replaceText("pixelfed.social"), ViewActions.closeSoftKeyboard())
onView(withId(R.id.connect_instance_button)).perform(click())
onView(withId(R.id.editText)).perform(scrollTo()).perform(ViewActions.replaceText("pixelfed.social"), ViewActions.closeSoftKeyboard())
onView(withId(R.id.connect_instance_button)).perform(scrollTo()).perform(click())
Thread.sleep(5000)
Thread.sleep(3000)
intended(expectedIntent)
}
@Test
fun launchesInstanceInfo() {
ActivityScenario.launch(LoginActivity::class.java)
val expectedIntent: Matcher<Intent> = allOf(
hasAction(ACTION_VIEW),
hasDataString(containsString("pixelfed.org/join"))
)
onView(withId(R.id.whatsAnInstanceTextView)).perform(click())
onView(withId(R.id.whatsAnInstanceTextView)).perform(scrollTo()).perform(click())
Thread.sleep(1000)
Thread.sleep(10000)
intended(expectedIntent)
}
@After
fun after() {
Intents.release()
}
}
@RunWith(AndroidJUnit4::class)

View File

@ -1,29 +1,23 @@
package com.h.pixeldroid
import android.content.Context
import android.view.Gravity
import android.view.View
import android.widget.EditText
import android.widget.TextView
import androidx.test.core.app.ActivityScenario
import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.UiController
import androidx.test.espresso.ViewAction
import androidx.test.espresso.action.*
import androidx.test.espresso.action.ViewActions
import androidx.test.espresso.assertion.ViewAssertions.matches
import androidx.test.espresso.contrib.DrawerActions
import androidx.test.espresso.contrib.DrawerMatchers
import androidx.test.espresso.contrib.NavigationViewActions
import androidx.test.espresso.contrib.RecyclerViewActions.actionOnItemAtPosition
import androidx.test.espresso.matcher.ViewMatchers.*
import androidx.test.ext.junit.rules.ActivityScenarioRule
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import com.google.android.material.tabs.TabLayout
import com.h.pixeldroid.fragments.feeds.ViewHolder
import com.h.pixeldroid.fragments.feeds.PostViewHolder
import com.h.pixeldroid.testUtility.CustomMatchers.Companion.clickChildViewWithId
import com.h.pixeldroid.testUtility.CustomMatchers.Companion.first
import com.h.pixeldroid.testUtility.CustomMatchers.Companion.getText
import com.h.pixeldroid.testUtility.CustomMatchers.Companion.slowSwipeUp
import com.h.pixeldroid.testUtility.CustomMatchers.Companion.typeTextInViewWithId
import com.h.pixeldroid.testUtility.MockServer
import org.hamcrest.BaseMatcher
import org.hamcrest.Matcher
import org.junit.Before
import org.junit.Rule
import org.junit.Test
@ -33,84 +27,6 @@ import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class MockedServerTest {
private fun <T> first(matcher: Matcher<T>): Matcher<T>? {
return object : BaseMatcher<T>() {
var isFirst = true
override fun describeTo(description: org.hamcrest.Description?) {
description?.appendText("first matching item")
}
override fun matches(item: Any?): Boolean {
if (isFirst && matcher.matches(item)) {
isFirst = false
return true
}
return false
}
}
}
/**
* @param percent can be 1 or 0
* 1: swipes all the way up
* 0: swipes half way up
*/
private fun slowSwipeUp(percent: Boolean) : ViewAction {
return ViewActions.actionWithAssertions(
GeneralSwipeAction(
Swipe.SLOW,
GeneralLocation.BOTTOM_CENTER,
if(percent) GeneralLocation.TOP_CENTER else GeneralLocation.CENTER,
Press.FINGER)
)
}
fun getText(matcher: Matcher<View?>?): String? {
val stringHolder = arrayOf<String?>(null)
onView(matcher).perform(object : ViewAction {
override fun getConstraints(): Matcher<View> {
return isAssignableFrom(TextView::class.java)
}
override fun getDescription(): String {
return "getting text from a TextView"
}
override fun perform(
uiController: UiController,
view: View
) {
val tv = view as TextView //Save, because of check in getConstraints()
stringHolder[0] = tv.text.toString()
}
})
return stringHolder[0]
}
private fun clickChildViewWithId(id: Int) = object : ViewAction {
override fun getConstraints() = null
override fun getDescription() = "click child view with id $id"
override fun perform(uiController: UiController, view: View) {
val v = view.findViewById<View>(id)
v.performClick()
}
}
private fun typeTextInViewWithId(id: Int, text: String) = object : ViewAction {
override fun getConstraints() = null
override fun getDescription() = "click child view with id $id"
override fun perform(uiController: UiController, view: View) {
val v = view.findViewById<EditText>(id)
v.text.append(text)
}
}
private val mockServer = MockServer()
@ -234,22 +150,43 @@ class MockedServerTest {
Thread.sleep(1000)
//Get initial like count
val likes = getText(withId(R.id.nlikes))
val likes = getText(first(withId(R.id.nlikes)))
//Like the post
onView(withId(R.id.list))
.perform(actionOnItemAtPosition<ViewHolder>
.perform(actionOnItemAtPosition<PostViewHolder>
(0, clickChildViewWithId(R.id.liker)))
Thread.sleep(100)
//Unlike the post
onView(withId(R.id.list))
.perform(actionOnItemAtPosition<ViewHolder>
.perform(actionOnItemAtPosition<PostViewHolder>
(0, clickChildViewWithId(R.id.liker)))
//...
Thread.sleep(100)
//Profit
onView(withId(R.id.nlikes)).check(matches((withText(likes))))
onView(first(withId(R.id.nlikes))).check(matches((withText(likes))))
}
@Test
fun clickingLikeButtonFails() {
ActivityScenario.launch(MainActivity::class.java)
Thread.sleep(1000)
//Get initial like count
val likes = getText(first(withId(R.id.nlikes)))
//Like the post
onView(withId(R.id.list))
.perform(actionOnItemAtPosition<PostViewHolder>
(2, clickChildViewWithId(R.id.liker)))
Thread.sleep(100)
//...
Thread.sleep(100)
//Profit
onView((withId(R.id.list))).check(matches(isDisplayed()))
}
@Test
@ -259,7 +196,7 @@ class MockedServerTest {
//Get initial like count
onView(withId(R.id.list))
.perform(actionOnItemAtPosition<ViewHolder>
.perform(actionOnItemAtPosition<PostViewHolder>
(0, clickChildViewWithId(R.id.username)))
Thread.sleep(1000)
@ -275,7 +212,7 @@ class MockedServerTest {
//Get initial like count
onView(withId(R.id.list))
.perform(actionOnItemAtPosition<ViewHolder>
.perform(actionOnItemAtPosition<PostViewHolder>
(0, clickChildViewWithId(R.id.profilePic)))
Thread.sleep(1000)
@ -284,16 +221,86 @@ class MockedServerTest {
onView(withId(R.id.accountNameTextView)).check(matches(isDisplayed()))
}
@Test
fun clickingReblogButtonWorks() {
ActivityScenario.launch(MainActivity::class.java)
Thread.sleep(1000)
//Get initial like count
val shares = getText(first(withId(R.id.nshares)))
//Reblog the post
onView(withId(R.id.list))
.perform(actionOnItemAtPosition<PostViewHolder>
(0, clickChildViewWithId(R.id.reblogger)))
Thread.sleep(100)
//UnReblog the post
onView(withId(R.id.list))
.perform(actionOnItemAtPosition<PostViewHolder>
(0, clickChildViewWithId(R.id.reblogger)))
//...
Thread.sleep(100)
//Profit
onView(first(withId(R.id.nshares))).check(matches((withText(shares))))
}
@Test
fun clickingMentionOpensProfile() {
ActivityScenario.launch(MainActivity::class.java)
Thread.sleep(1000)
//Click the mention
onView(withId(R.id.list))
.perform(actionOnItemAtPosition<PostViewHolder>
(0, clickChildViewWithId(R.id.description)))
//Wait a bit
Thread.sleep(1000)
//Check that the Profile is shown
onView(first(withId(R.id.username))).check(matches(isDisplayed()))
}
@Test
fun clickingHashTagsWorks() {
ActivityScenario.launch(MainActivity::class.java)
Thread.sleep(1000)
//Click the hashtag
onView(withId(R.id.list))
.perform(actionOnItemAtPosition<PostViewHolder>
(1, clickChildViewWithId(R.id.description)))
//Wait a bit
Thread.sleep(1000)
//Check that the HashTag was indeed clicked
//Doesn't do anything for now
onView(withId(R.id.list)).check(matches(isDisplayed()))
}
@Test
fun clickingCommentButtonOpensCommentSection() {
ActivityScenario.launch(MainActivity::class.java)
Thread.sleep(1000)
//Click comment button and then try to see if the commenter exists
//Click comment button 3 times and then try to see if the commenter exists
onView(withId(R.id.list))
.perform(actionOnItemAtPosition<ViewHolder>
.perform(actionOnItemAtPosition<PostViewHolder>
(0, clickChildViewWithId(R.id.commenter)))
Thread.sleep(1000)
onView(withId(R.id.commentIn))
Thread.sleep(100)
onView(withId(R.id.list))
.perform(actionOnItemAtPosition<PostViewHolder>
(0, clickChildViewWithId(R.id.commenter)))
Thread.sleep(100)
onView(withId(R.id.list))
.perform(actionOnItemAtPosition<PostViewHolder>
(0, clickChildViewWithId(R.id.commenter)))
onView(first(withId(R.id.commentIn)))
.check(matches(hasDescendant(withId(R.id.editComment))))
}
@ -303,13 +310,25 @@ class MockedServerTest {
Thread.sleep(1000)
//Open the comment section
onView(withId(R.id.list))
.perform(actionOnItemAtPosition<ViewHolder>
.perform(actionOnItemAtPosition<PostViewHolder>
(0, clickChildViewWithId(R.id.ViewComments)))
Thread.sleep(1000)
onView(withId(R.id.commentContainer))
onView(first(withId(R.id.commentContainer)))
.check(matches(hasDescendant(withId(R.id.comment))))
}
@Test
fun clickingViewCommentFails() {
ActivityScenario.launch(MainActivity::class.java)
Thread.sleep(1000)
//Open the comment section
onView(withId(R.id.list))
.perform(actionOnItemAtPosition<PostViewHolder>
(2, clickChildViewWithId(R.id.ViewComments)))
Thread.sleep(1000)
onView(withId(R.id.list)).check(matches(isDisplayed()))
}
@Test
fun postingACommentWorks() {
ActivityScenario.launch(MainActivity::class.java)
@ -317,23 +336,21 @@ class MockedServerTest {
//Open the comment section
onView(withId(R.id.list))
.perform(actionOnItemAtPosition<ViewHolder>
.perform(actionOnItemAtPosition<PostViewHolder>
(0, clickChildViewWithId(R.id.commenter)))
onView(withId(R.id.list)).perform(slowSwipeUp(true))
onView(withId(R.id.list)).perform(slowSwipeUp(false))
onView(withId(R.id.list)).perform(slowSwipeUp(false))
Thread.sleep(1000)
onView(withId(R.id.list))
.perform(actionOnItemAtPosition<ViewHolder>
.perform(actionOnItemAtPosition<PostViewHolder>
(0, typeTextInViewWithId(R.id.editComment, "test")))
onView(withId(R.id.list))
.perform(actionOnItemAtPosition<ViewHolder>
.perform(actionOnItemAtPosition<PostViewHolder>
(0, clickChildViewWithId(R.id.submitComment)))
Thread.sleep(1000)
onView(withId(R.id.commentContainer))
onView(first(withId(R.id.commentContainer)))
.check(matches(hasDescendant(withId(R.id.comment))))
}
}

View File

@ -0,0 +1,95 @@
package com.h.pixeldroid.testUtility
import android.view.View
import android.widget.EditText
import android.widget.TextView
import androidx.test.espresso.Espresso
import androidx.test.espresso.UiController
import androidx.test.espresso.ViewAction
import androidx.test.espresso.action.*
import androidx.test.espresso.matcher.ViewMatchers
import org.hamcrest.BaseMatcher
import org.hamcrest.Matcher
abstract class CustomMatchers {
companion object {
fun <T> first(matcher: Matcher<T>): Matcher<T>? {
return object : BaseMatcher<T>() {
var isFirst = true
override fun describeTo(description: org.hamcrest.Description?) {
description?.appendText("first matching item")
}
override fun matches(item: Any?): Boolean {
if (isFirst && matcher.matches(item)) {
isFirst = false
return true
}
return false
}
}
}
/**
* @param percent can be 1 or 0
* 1: swipes all the way up
* 0: swipes half way up
*/
fun slowSwipeUp(percent: Boolean) : ViewAction {
return ViewActions.actionWithAssertions(
GeneralSwipeAction(
Swipe.SLOW,
GeneralLocation.BOTTOM_CENTER,
if(percent) GeneralLocation.TOP_CENTER else GeneralLocation.CENTER,
Press.FINGER)
)
}
fun getText(matcher: Matcher<View?>?): String? {
val stringHolder = arrayOf<String?>(null)
Espresso.onView(matcher).perform(object : ViewAction {
override fun getConstraints(): Matcher<View> {
return ViewMatchers.isAssignableFrom(TextView::class.java)
}
override fun getDescription(): String {
return "getting text from a TextView"
}
override fun perform(
uiController: UiController,
view: View
) {
val tv = view as TextView //Save, because of check in getConstraints()
stringHolder[0] = tv.text.toString()
}
})
return stringHolder[0]
}
fun clickChildViewWithId(id: Int) = object : ViewAction {
override fun getConstraints() = null
override fun getDescription() = "click child view with id $id"
override fun perform(uiController: UiController, view: View) {
val v = view.findViewById<View>(id)
v.performClick()
}
}
fun typeTextInViewWithId(id: Int, text: String) = object : ViewAction {
override fun getConstraints() = null
override fun getDescription() = "click child view with id $id"
override fun perform(uiController: UiController, view: View) {
val v = view.findViewById<EditText>(id)
v.text.append(text)
}
}
}
}

File diff suppressed because one or more lines are too long

View File

@ -77,6 +77,20 @@ interface PixelfedAPI {
@Field("language") language : String? = null
) : Call<Status>
@FormUrlEncoded
@POST("/api/v1/statuses/{id}/reblog")
fun reblogStatus(
@Header("Authorization") authorization: String,
@Path("id") statusId: String,
@Field("visibility") visibility: String? = null
) : Call<Status>
@POST("/api/v1/statuses/{id}/unreblog")
fun undoReblogStatus(
@Path("id") statusId: String,
@Header("Authorization") authorization: String
) : Call<Status>
//Used in our case to retrieve comments for a given status
@GET("/api/v1/statuses/{id}/context")
fun statusComments(
@ -133,6 +147,12 @@ interface PixelfedAPI {
@Path("id") account_id: String? = null
): Call<List<Status>>
@GET("/api/v1/accounts/{id}")
fun getAccount(
@Header("Authorization") authorization: String,
@Path("id") accountId : String
): Call<Account>
companion object {
fun create(baseUrl: String): PixelfedAPI {
return Retrofit.Builder()

View File

@ -12,9 +12,8 @@ import com.bumptech.glide.Glide
import com.h.pixeldroid.BuildConfig
import com.h.pixeldroid.R
import com.h.pixeldroid.api.PixelfedAPI
import com.h.pixeldroid.fragments.feeds.ViewHolder
import com.h.pixeldroid.fragments.feeds.PostViewHolder
import com.h.pixeldroid.objects.Status
import com.h.pixeldroid.objects.Status.Companion.POST_TAG
import kotlinx.android.synthetic.main.post_fragment.view.*
@ -34,15 +33,19 @@ class PostFragment : Fragment() {
status?.setupPost(root, picRequest, root.postPicture, root.profilePic)
//Setup arguments needed for the onclicklisteners
val holder = ViewHolder(root, requireContext())
val holder = PostViewHolder(root, requireContext())
val preferences = requireActivity().getSharedPreferences(
"${BuildConfig.APPLICATION_ID}.pref", Context.MODE_PRIVATE
)
val accessToken = preferences.getString("accessToken", "")
val api = PixelfedAPI.create("${preferences.getString("domain", "")}")
status?.setDescription(root, api, "Bearer $accessToken")
//Activate onclickListeners
status?.activateLiker(holder, api, "Bearer $accessToken")
status?.activateLiker(holder, api, "Bearer $accessToken", status.favourited)
status?.activateReblogger(holder, api, "Bearer $accessToken", status.reblogged)
status?.activateCommenter(holder, api, "Bearer $accessToken")
status?.showComments(holder, api, "Bearer $accessToken")

View File

@ -13,6 +13,7 @@ import androidx.lifecycle.Observer
import androidx.paging.LivePagedListBuilder
import androidx.paging.PagedList
import androidx.recyclerview.widget.RecyclerView
import at.connyduck.sparkbutton.SparkButton
import com.bumptech.glide.Glide
import com.bumptech.glide.ListPreloader
import com.bumptech.glide.RequestBuilder
@ -23,7 +24,7 @@ import com.h.pixeldroid.objects.Status
import retrofit2.Call
class HomeFragment : FeedFragment<Status, ViewHolder>() {
class HomeFragment : FeedFragment<Status, PostViewHolder>() {
lateinit var picRequest: RequestBuilder<Drawable>
@ -81,21 +82,21 @@ class HomeFragment : FeedFragment<Status, ViewHolder>() {
/**
* [RecyclerView.Adapter] that can display a list of Statuses
*/
inner class HomeRecyclerViewAdapter()
: FeedsRecyclerViewAdapter<Status, ViewHolder>() {
inner class HomeRecyclerViewAdapter
: FeedsRecyclerViewAdapter<Status, PostViewHolder>() {
private val api = pixelfedAPI
private val credential = "Bearer $accessToken"
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
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 ViewHolder(view, context)
return PostViewHolder(view, context)
}
/**
* Binds the different elements of the Post Model to the view holder
*/
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
override fun onBindViewHolder(holder: PostViewHolder, position: Int) {
val post = getItem(position) ?: return
val metrics = context.resources.displayMetrics
//Limit the height of the different images
@ -105,17 +106,20 @@ class HomeFragment : FeedFragment<Status, ViewHolder>() {
//Setup the post layout
post.setupPost(holder.postView, picRequest, holder.postPic, holder.profilePic)
//Set initial favorite toggle value
holder.isLiked = post.favourited
//Set the special HTML text
post.setDescription(holder.postView, api, credential)
//Activate liker
post.activateLiker(holder, api, credential)
post.activateLiker(holder, api, credential, post.favourited)
//Show comments
post.showComments(holder, api, credential)
//Activate Commenter
post.activateCommenter(holder, api, credential)
//Activate Reblogger
post.activateReblogger(holder, api ,credential, post.reblogged)
}
override fun getPreloadItems(position: Int): MutableList<Status> {
@ -132,7 +136,7 @@ class HomeFragment : FeedFragment<Status, ViewHolder>() {
/**
* Represents the posts that will be contained within the feed
*/
class ViewHolder(val postView: View, val context: android.content.Context) : RecyclerView.ViewHolder(postView) {
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)
@ -140,12 +144,15 @@ class ViewHolder(val postView: View, val context: android.content.Context) : Rec
val description : TextView = postView.findViewById(R.id.description)
val nlikes : TextView = postView.findViewById(R.id.nlikes)
val nshares : TextView = postView.findViewById(R.id.nshares)
val liker : ImageView = postView.findViewById(R.id.liker)
//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)
var isLiked : Boolean = false
}

View File

@ -27,6 +27,7 @@ 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 kotlinx.android.synthetic.main.fragment_notifications.view.*
import retrofit2.Call
@ -145,7 +146,15 @@ class NotificationsFragment : FeedFragment<Notification, NotificationsFragment.N
setNotificationType(notification.type, notification.account.username, holder.notificationType)
holder.postDescription.text = notification.status?.content ?: ""
//Convert HTML to clickable text
holder.postDescription.text =
parseHTMLText(
notification.status?.content ?: "",
notification.status?.mentions,
pixelfedAPI,
context,
"Bearer $accessToken"
)
with(holder.mView) {
@ -167,11 +176,11 @@ class NotificationsFragment : FeedFragment<Notification, NotificationsFragment.N
}
Notification.NotificationType.reblog -> {
setNotificationTypeTextView(context, R.string.shared_notification, R.drawable.ic_share)
setNotificationTypeTextView(context, R.string.shared_notification, R.drawable.ic_reblog_blue)
}
Notification.NotificationType.favourite -> {
setNotificationTypeTextView(context, R.string.liked_notification, R.drawable.ic_heart)
setNotificationTypeTextView(context, R.string.liked_notification, R.drawable.ic_like_full)
}
}
textView.text = format.format(username)

View File

@ -3,13 +3,18 @@ package com.h.pixeldroid.objects
import android.content.Context
import android.content.Intent
import android.graphics.Typeface
import android.util.Log
import android.view.View
import android.widget.ImageView
import android.widget.TextView
import androidx.core.content.ContextCompat.startActivity
import com.h.pixeldroid.ProfileActivity
import com.h.pixeldroid.R
import com.h.pixeldroid.api.PixelfedAPI
import com.h.pixeldroid.utils.ImageConverter
import retrofit2.Call
import retrofit2.Callback
import retrofit2.Response
import java.io.Serializable
/*
@ -18,65 +23,92 @@ https://docs.joinmastodon.org/entities/account/
*/
data class Account(
//Base attributes
val id: String,
val username: String,
val acct: String,
val url: String, //HTTPS URL
//Display attributes
val display_name: String,
val note: String, //HTML
val avatar: String, //URL
val avatar_static: String, //URL
val header: String, //URL
val header_static: String, //URL
val locked: Boolean,
val emojis: List<Emoji>,
val discoverable: Boolean,
//Statistical attributes
val created_at: String, //ISO 8601 Datetime (maybe can use a date type)
val statuses_count: Int,
val followers_count: Int,
val following_count: Int,
//Optional attributes
val moved: Account? = null,
val fields: List<Field>? = emptyList(),
val bot: Boolean = false,
val source: Source? = null
//Base attributes
val id: String,
val username: String,
val acct: String,
val url: String, //HTTPS URL
//Display attributes
val display_name: String,
val note: String, //HTML
val avatar: String, //URL
val avatar_static: String, //URL
val header: String, //URL
val header_static: String, //URL
val locked: Boolean,
val emojis: List<Emoji>,
val discoverable: Boolean,
//Statistical attributes
val created_at: String, //ISO 8601 Datetime (maybe can use a date type)
val statuses_count: Int,
val followers_count: Int,
val following_count: Int,
//Optional attributes
val moved: Account? = null,
val fields: List<Field>? = emptyList(),
val bot: Boolean = false,
val source: Source? = null
) : Serializable {
companion object {
const val ACCOUNT_TAG = "AccountTag"
companion object {
const val ACCOUNT_TAG = "AccountTag"
/**
* @brief Opens an activity of the profile withn the given id
*/
fun getAccountFromId(id: String, api : PixelfedAPI, context: Context, credential: String) {
Log.e("ACCOUNT_ID", id)
api.getAccount(credential, id).enqueue( object : Callback<Account> {
override fun onFailure(call: Call<Account>, t: Throwable) {
Log.e("GET ACCOUNT ERROR", t.toString())
}
override fun onResponse(
call: Call<Account>,
response: Response<Account>
) {
if(response.code() == 200) {
val account = response.body()!!
//Open the account page in a seperate activity
account.openProfile(context)
} else {
Log.e("ERROR CODE", response.code().toString())
}
}
})
}
}
// Open profile activity with given account
fun openProfile(context: Context) {
val intent = Intent(context, ProfileActivity::class.java)
intent.putExtra(Account.ACCOUNT_TAG, this)
startActivity(context, intent, null)
}
// Populate myProfile page with user's data
fun setContent(view: View) {
val profilePicture = view.findViewById<ImageView>(R.id.profilePictureImageView)
ImageConverter.setRoundImageFromURL(view, this.avatar, profilePicture)
// Open profile activity with given account
fun openProfile(context: Context) {
val intent = Intent(context, ProfileActivity::class.java)
intent.putExtra(Account.ACCOUNT_TAG, this)
startActivity(context, intent, null)
}
// Populate myProfile page with user's data
fun setContent(view: View) {
val profilePicture = view.findViewById<ImageView>(R.id.profilePictureImageView)
ImageConverter.setRoundImageFromURL(view, this.avatar, profilePicture)
val description = view.findViewById<TextView>(R.id.descriptionTextView)
description.text = this.note
val description = view.findViewById<TextView>(R.id.descriptionTextView)
description.text = this.note
val accountName = view.findViewById<TextView>(R.id.accountNameTextView)
accountName.text = this.username
accountName.setTypeface(null, Typeface.BOLD)
val accountName = view.findViewById<TextView>(R.id.accountNameTextView)
accountName.text = this.username
accountName.setTypeface(null, Typeface.BOLD)
val nbPosts = view.findViewById<TextView>(R.id.nbPostsTextView)
nbPosts.text = "${this.statuses_count}\nPosts"
nbPosts.setTypeface(null, Typeface.BOLD)
val nbPosts = view.findViewById<TextView>(R.id.nbPostsTextView)
nbPosts.text = "${this.statuses_count}\nPosts"
nbPosts.setTypeface(null, Typeface.BOLD)
val nbFollowers = view.findViewById<TextView>(R.id.nbFollowersTextView)
nbFollowers.text = "${this.followers_count}\nFollowers"
nbFollowers.setTypeface(null, Typeface.BOLD)
val nbFollowers = view.findViewById<TextView>(R.id.nbFollowersTextView)
nbFollowers.text = "${this.followers_count}\nFollowers"
nbFollowers.setTypeface(null, Typeface.BOLD)
val nbFollowing = view.findViewById<TextView>(R.id.nbFollowingTextView)
nbFollowing.text = "${this.following_count}\nFollowing"
nbFollowing.setTypeface(null, Typeface.BOLD)
}
val nbFollowing = view.findViewById<TextView>(R.id.nbFollowingTextView)
nbFollowing.text = "${this.following_count}\nFollowing"
nbFollowing.setTypeface(null, Typeface.BOLD)
}
}

View File

@ -2,5 +2,10 @@ package com.h.pixeldroid.objects
import java.io.Serializable
class Mention : Serializable {
}
data class Mention(
//Mentioned user
val id: String,
val username : String,
val acct : String, //URI of mentioned user (username if local, else username@domain)
val url : String //URL of mentioned user's profile
) : Serializable

View File

@ -1,22 +1,32 @@
package com.h.pixeldroid.objects
import android.content.Context
import android.graphics.Typeface
import android.graphics.drawable.Drawable
import android.os.Build
import android.text.Html
import android.text.Spanned
import android.text.method.LinkMovementMethod
import android.util.Log
import android.view.View
import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.TextView
import android.widget.Toast
import androidx.core.text.toSpanned
import com.bumptech.glide.RequestBuilder
import com.h.pixeldroid.R
import com.h.pixeldroid.api.PixelfedAPI
import com.h.pixeldroid.fragments.feeds.ViewHolder
import com.h.pixeldroid.fragments.feeds.PostViewHolder
import com.h.pixeldroid.utils.HtmlUtils.Companion.parseHTMLText
import com.h.pixeldroid.utils.ImageConverter
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.undoReblogPost
import java.io.Serializable
/*
@ -70,17 +80,21 @@ data class Status(
fun getProfilePicUrl() : String? = account.avatar
fun getPostPreviewURL() : String? = media_attachments?.getOrNull(0)?.preview_url
fun getDescription() : CharSequence {
val description = content as CharSequence
/**
* @brief returns the parsed version of the HTML description
*/
private fun getDescription(api: PixelfedAPI, context: Context, credential: String) : Spanned {
val description = content
if(description.isEmpty()) {
return "No description"
return "No description".toSpanned()
}
return description
return parseHTMLText(description, mentions, api, context, credential)
}
fun getUsername() : CharSequence {
var name = account?.display_name
if (name.isNullOrEmpty()) {
if (name.isEmpty()) {
name = account?.username
}
return name!!
@ -112,8 +126,6 @@ data class Status(
usernameDesc.text = this.getUsername()
usernameDesc.setTypeface(null, Typeface.BOLD)
rootView.findViewById<TextView>(R.id.description).text = this.getDescription()
val nlikes = rootView.findViewById<TextView>(R.id.nlikes)
nlikes.text = this.getNLikes()
nlikes.setTypeface(null, Typeface.BOLD)
@ -135,25 +147,60 @@ data class Status(
rootView.findViewById<LinearLayout>(R.id.commentIn).visibility = View.GONE
}
fun activateLiker(
holder : ViewHolder,
api: PixelfedAPI,
credential: String
fun setDescription(rootView: View, api : PixelfedAPI, credential: String) {
val desc = rootView.findViewById<TextView>(R.id.description)
desc.text = this.getDescription(api, rootView.context, credential)
desc.movementMethod = LinkMovementMethod.getInstance()
}
fun activateReblogger(
holder : PostViewHolder,
api : PixelfedAPI,
credential: String,
isReblogged : Boolean
) {
//Activate the liker
holder.liker.setOnClickListener {
if (holder.isLiked) {
//Unlike the post
unLikePostCall(holder, api, credential, this)
//Set initial button state
holder.reblogger.isChecked = isReblogged
//Activate the button
holder.reblogger.setEventListener { _, buttonState ->
if (buttonState) {
Log.e("REBLOG", "Reblogged post")
// Button is active
reblogPost(holder, api, credential, this)
} else {
//like the post
likePostCall(holder, api, credential, this)
Log.e("REBLOG", "Undo Reblogged post")
// Button is inactive
undoReblogPost(holder, api, credential, this)
}
}
}
fun activateLiker(
holder : PostViewHolder,
api: PixelfedAPI,
credential: String,
isLiked: Boolean
) {
//Set initial state
holder.liker.isChecked = isLiked
//Activate the liker
holder.liker.setEventListener { _, buttonState ->
if (buttonState) {
// Button is active
likePostCall(holder, api, credential, this)
} else {
// Button is inactive
unLikePostCall(holder, api, credential, this)
}
}
}
fun showComments(
holder : ViewHolder,
holder : PostViewHolder,
api: PixelfedAPI,
credential: String
) {
@ -172,7 +219,7 @@ data class Status(
}
fun activateCommenter(
holder : ViewHolder,
holder : PostViewHolder,
api: PixelfedAPI,
credential: String
) {

View File

@ -0,0 +1,126 @@
package com.h.pixeldroid.utils
import android.content.Context
import android.os.Build
import android.text.Html
import android.text.SpannableStringBuilder
import android.text.Spanned
import android.text.style.ClickableSpan
import android.text.style.URLSpan
import android.util.Log
import android.view.View
import android.widget.Toast
import androidx.core.text.toSpanned
import com.h.pixeldroid.api.PixelfedAPI
import com.h.pixeldroid.objects.Account.Companion.getAccountFromId
import com.h.pixeldroid.objects.Mention
import com.h.pixeldroid.utils.customSpans.ClickableSpanNoUnderline
import java.net.URI
import java.net.URISyntaxException
import java.util.Locale
class HtmlUtils {
companion object {
private fun fromHtml(html: String): Spanned {
val result: Spanned = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
Html.fromHtml(html, Html.FROM_HTML_MODE_LEGACY)
} else {
Html.fromHtml(html)
}
return result.trim().toSpanned()
}
private fun getDomain(urlString: String?): String {
val uri: URI
try {
uri = URI(urlString!!)
} catch (e: URISyntaxException) {
return ""
}
val host: String = uri.host
return if (host.startsWith("www.")) {
host.substring(4)
} else {
host
}
}
fun parseHTMLText(
text : String,
mentions: List<Mention>?,
api : PixelfedAPI,
context: Context,
credential: String
) : Spanned {
//Convert text to spannable
val content = fromHtml(text)
//Retrive all links that should be made clickable
val builder = SpannableStringBuilder(content)
val urlSpans = content.getSpans(0, content.length, URLSpan::class.java)
for(span in urlSpans) {
val start = builder.getSpanStart(span)
val end = builder.getSpanEnd(span)
val flags = builder.getSpanFlags(span)
val text = builder.subSequence(start, end)
var customSpan: ClickableSpan? = null
//Handle hashtags
if (text[0] == '#') {
val tag = text.subSequence(1, text.length).toString()
customSpan = object : ClickableSpanNoUnderline() {
override fun onClick(widget: View) {
Toast.makeText(context, tag, Toast.LENGTH_SHORT).show()
}
}
}
//Handle mentions
if(text[0] == '@' && !mentions.isNullOrEmpty()) {
val accountUsername = text.subSequence(1, text.length).toString()
var id: String? = null
//Go through all mentions stored in the status
for (mention in mentions) {
if (mention.username.toLowerCase(Locale.ROOT)
== accountUsername.toLowerCase(Locale.ROOT)
) {
id = mention.id
//Mentions can be of users in other domains
if (mention.url.contains(getDomain(span.url))) {
break
}
}
}
//Check that we found a user for the given mention
if (id != null) {
val accountId: String = id
customSpan = object : ClickableSpanNoUnderline() {
override fun onClick(widget: View) {
Log.e("MENTION", "CLICKED")
//Retrieve the account for the given profile
getAccountFromId(accountId, api, context, credential)
}
}
}
}
builder.removeSpan(span);
builder.setSpan(customSpan, start, end, flags);
// Add zero-width space after links in end of line to fix its too large hitbox.
if (end >= builder.length || builder.subSequence(end, end + 1).toString() == "\n") {
builder.insert(end, "\u200B")
}
}
return builder
}
}
}

View File

@ -1,49 +1,110 @@
package com.h.pixeldroid.utils
import android.graphics.Typeface
import android.util.Log
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.LinearLayout
import android.widget.TextView
import android.widget.Toast
import androidx.cardview.widget.CardView
import com.h.pixeldroid.R
import com.h.pixeldroid.api.PixelfedAPI
import com.h.pixeldroid.fragments.feeds.ViewHolder
import com.h.pixeldroid.objects.Account
import com.h.pixeldroid.fragments.feeds.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
class PostUtils {
abstract class PostUtils {
companion object {
fun toggleCommentInput(
holder : ViewHolder
holder : PostViewHolder
) {
//Toggle comment button
holder.commenter.setOnClickListener {
when(holder.commentIn.visibility) {
View.VISIBLE -> holder.commentIn.visibility = View.GONE
View.INVISIBLE -> holder.commentIn.visibility = View.VISIBLE
View.GONE -> holder.commentIn.visibility = View.VISIBLE
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 likePostCall(
holder : ViewHolder,
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.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.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>) {
@ -52,9 +113,10 @@ class PostUtils {
//Update shown like count and internal like toggle
holder.nlikes.text = resp.getNLikes()
holder.isLiked = resp.favourited
holder.liker.isChecked = resp.favourited
} else {
Log.e("RESPOSE_CODE", response.code().toString())
Log.e("RESPONSE_CODE", response.code().toString())
holder.liker.isChecked = false
}
}
@ -62,14 +124,16 @@ class PostUtils {
}
fun unLikePostCall(
holder : ViewHolder,
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>) {
@ -78,9 +142,10 @@ class PostUtils {
//Update shown like count and internal like toggle
holder.nlikes.text = resp.getNLikes()
holder.isLiked = resp.favourited
holder.liker.isChecked = resp.favourited
} else {
Log.e("RESPOSE_CODE", response.code().toString())
Log.e("RESPONSE_CODE", response.code().toString())
holder.liker.isChecked = true
}
}
@ -89,7 +154,7 @@ class PostUtils {
}
fun postComment(
holder : ViewHolder,
holder : PostViewHolder,
api: PixelfedAPI,
credential: String,
post : Status
@ -131,7 +196,7 @@ class PostUtils {
}
fun retrieveComments(
holder : ViewHolder,
holder : PostViewHolder,
api: PixelfedAPI,
credential: String,
post : Status

View File

@ -0,0 +1,11 @@
package com.h.pixeldroid.utils.customSpans
import android.text.TextPaint
import android.text.style.ClickableSpan
abstract class ClickableSpanNoUnderline : ClickableSpan() {
override fun updateDrawState(ds: TextPaint) {
super.updateDrawState(ds)
ds.isUnderlineText = false
}
}

View File

@ -0,0 +1,5 @@
<vector android:height="24dp" android:tint="#1737A2"
android:viewportHeight="24.0" android:viewportWidth="24.0"
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="#FF000000" android:pathData="M20,2H4c-1.1,0 -2,0.9 -2,2v18l4,-4h14c1.1,0 2,-0.9 2,-2V4c0,-1.1 -0.9,-2 -2,-2z"/>
</vector>

View File

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24.0"
android:viewportHeight="24.0">
<path
android:fillColor="#FF000000"
android:pathData="M20,2L4,2c-1.1,0 -2,0.9 -2,2v18l4,-4h14c1.1,0 2,-0.9 2,-2L22,4c0,-1.1 -0.9,-2 -2,-2zM20,16L6,16l-2,2L4,4h16v12z"/>
</vector>

View File

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24.0"
android:viewportHeight="24.0">
<path
android:fillColor="#FF000000"
android:pathData="M20,2H4c-1.1,0 -2,0.9 -2,2v18l4,-4h14c1.1,0 2,-0.9 2,-2V4c0,-1.1 -0.9,-2 -2,-2z"/>
</vector>

View File

@ -0,0 +1,6 @@
<vector android:height="21.6dp" android:viewportHeight="99"
android:viewportWidth="110" android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="#00000000"
android:pathData="M54.5,94.5C58.2,90.74 79.39,69.25 92.63,55.83C104.5,43.8 104.24,26.26 93.46,15.39C82.67,4.5 65.23,4.55 54.5,15.48C43.76,4.55 26.32,4.5 15.54,15.38C4.75,26.25 4.5,43.79 16.36,55.83C29.6,69.25 50.79,90.74 54.5,94.5Z"
android:strokeColor="#000000" android:strokeWidth="9"/>
</vector>

View File

@ -0,0 +1,5 @@
<vector android:height="21.6dp" android:viewportHeight="99"
android:viewportWidth="110" android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="#c42f0a"
android:pathData="M54.5,94.5C58.2,90.74 79.39,69.25 92.63,55.83C104.5,43.8 104.24,26.26 93.46,15.39C82.67,4.5 65.23,4.55 54.5,15.48C43.76,4.55 26.32,4.5 15.54,15.38C4.75,26.25 4.5,43.79 16.36,55.83C29.6,69.25 50.79,90.74 54.5,94.5Z" android:strokeColor="#00000000"/>
</vector>

View File

@ -0,0 +1,15 @@
<vector android:height="24dp" android:viewportHeight="85"
android:viewportWidth="111" android:width="31.341177dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="#00000000"
android:pathData="M30.5,64.5L90.5,64.5L90.5,39.81"
android:strokeColor="#000000" android:strokeWidth="9"/>
<path android:fillColor="#000000"
android:pathData="M90.5,25.56L100,44.56L90.5,39.81L81,44.56Z"
android:strokeColor="#000000" android:strokeWidth="9"/>
<path android:fillColor="#00000000"
android:pathData="M80.5,18.5L20.5,18.5L20.5,44.19"
android:strokeColor="#000000" android:strokeWidth="9"/>
<path android:fillColor="#000000"
android:pathData="M20.5,58.44L11,39.44L20.5,44.19L30,39.44Z"
android:strokeColor="#000000" android:strokeWidth="9"/>
</vector>

View File

@ -0,0 +1,15 @@
<vector android:height="24dp" android:viewportHeight="85"
android:viewportWidth="111" android:width="31.341177dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="#00000000"
android:pathData="M30.5,64.5L90.5,64.5L90.5,39.81"
android:strokeColor="#0000ff" android:strokeWidth="9"/>
<path android:fillColor="#0000ff"
android:pathData="M90.5,25.56L100,44.56L90.5,39.81L81,44.56Z"
android:strokeColor="#0000ff" android:strokeWidth="9"/>
<path android:fillColor="#00000000"
android:pathData="M80.5,18.5L20.5,18.5L20.5,44.19"
android:strokeColor="#0000ff" android:strokeWidth="9"/>
<path android:fillColor="#0000ff"
android:pathData="M20.5,58.44L11,39.44L20.5,44.19L30,39.44Z"
android:strokeColor="#0000ff" android:strokeWidth="9"/>
</vector>

View File

@ -0,0 +1,5 @@
<vector android:height="24dp" android:tint="#1737A2"
android:viewportHeight="24.0" android:viewportWidth="24.0"
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="#FF000000" android:pathData="M2.01,21L23,12 2.01,3 2,10l15,2 -15,2z"/>
</vector>

View File

@ -1,4 +0,0 @@
<vector android:height="19.2dp" android:viewportHeight="512"
android:viewportWidth="640" android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="#FF000000" android:pathData="M629.657,343.598L528.971,444.284c-9.373,9.372 -24.568,9.372 -33.941,0L394.343,343.598c-9.373,-9.373 -9.373,-24.569 0,-33.941l10.823,-10.823c9.562,-9.562 25.133,-9.34 34.419,0.492L480,342.118L480,160L292.451,160a24.005,24.005 0,0 1,-16.971 -7.029l-16,-16C244.361,121.851 255.069,96 276.451,96L520,96c13.255,0 24,10.745 24,24v222.118l40.416,-42.792c9.285,-9.831 24.856,-10.054 34.419,-0.492l10.823,10.823c9.372,9.372 9.372,24.569 -0.001,33.941zM364.519,359.029A23.999,23.999 0,0 0,347.548 352L160,352L160,169.881l40.416,42.792c9.286,9.831 24.856,10.054 34.419,0.491l10.822,-10.822c9.373,-9.373 9.373,-24.569 0,-33.941L144.971,67.716c-9.373,-9.373 -24.569,-9.373 -33.941,0L10.343,168.402c-9.373,9.373 -9.373,24.569 0,33.941l10.822,10.822c9.562,9.562 25.133,9.34 34.419,-0.491L96,169.881L96,392c0,13.255 10.745,24 24,24h243.549c21.382,0 32.09,-25.851 16.971,-40.971l-16.001,-16z"/>
</vector>

View File

@ -1,200 +1,195 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
tools:context=".fragments.PostFragment">
<ScrollView
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent">
<androidx.cardview.widget.CardView
android:layout_marginTop="5dp"
android:layout_marginBottom="5dp"
android:layout_width="match_parent"
android:layout_height="wrap_content">
android:layout_height="wrap_content"
xmlns:sparkbutton="http://schemas.android.com/apk/res-auto"
tools:context=".fragments.PostFragment">
<androidx.cardview.widget.CardView
android:layout_marginTop="5dp"
android:layout_marginBottom="5dp"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<androidx.constraintlayout.widget.ConstraintLayout
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:clipChildren="false"
android:clipToPadding="false">
<ImageView
android:id="@+id/profilePic"
android:layout_width="50dp"
android:layout_height="50dp"
android:layout_margin="10dp"
android:src="@drawable/ic_default_user"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<TextView
android:id="@+id/username"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="12dp"
app:layout_constraintBottom_toBottomOf="@+id/profilePic"
app:layout_constraintStart_toEndOf="@+id/profilePic"
app:layout_constraintTop_toTopOf="@+id/profilePic"
tools:text="Account" />
<ImageView
android:id="@+id/postPicture"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
android:layout_marginTop="10dp"
android:adjustViewBounds="true"
app:layout_constraintTop_toBottomOf="@+id/profilePic"
tools:src="@color/browser_actions_bg_grey"/>
<LinearLayout
android:id="@+id/linearLayout3"
<ImageView
android:id="@+id/commenter"
android:layout_width="30dp"
android:layout_height="30dp"
android:importantForAccessibility="no"
android:padding="4dp"
android:src="@drawable/ic_comment_empty"
app:layout_constraintBottom_toBottomOf="@+id/liker"
app:layout_constraintEnd_toStartOf="@id/reblogger"
app:layout_constraintStart_toEndOf="@id/liker"
app:layout_constraintTop_toTopOf="@id/liker"
/>
<at.connyduck.sparkbutton.SparkButton
android:id="@+id/liker"
android:layout_width="30dp"
android:layout_height="30dp"
android:layout_marginTop="4dp"
android:layout_marginBottom="4dp"
android:clipToPadding="false"
android:importantForAccessibility="no"
android:padding="4dp"
sparkbutton:activeImage="@drawable/ic_like_full"
sparkbutton:iconSize="28dp"
sparkbutton:inactiveImage="@drawable/ic_like_empty"
sparkbutton:primaryColor="@color/heart_red"
sparkbutton:secondaryColor="@color/black"
app:layout_constraintEnd_toStartOf="@id/commenter"
app:layout_constraintHorizontal_chainStyle="spread"
app:layout_constraintStart_toStartOf="@id/profilePic"
app:layout_constraintTop_toBottomOf="@id/postPicture"/>
<at.connyduck.sparkbutton.SparkButton
android:id="@+id/reblogger"
android:layout_width="30dp"
android:layout_height="30dp"
android:clipToPadding="false"
android:importantForAccessibility="no"
android:padding="4dp"
app:layout_constraintBottom_toBottomOf="@+id/commenter"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@+id/commenter"
app:layout_constraintTop_toTopOf="@+id/commenter"
sparkbutton:activeImage="@drawable/ic_reblog_blue"
sparkbutton:iconSize="28dp"
sparkbutton:inactiveImage="@drawable/ic_reblog"
sparkbutton:primaryColor="@color/share_blue"
sparkbutton:secondaryColor="@color/black"/>
<TextView
android:id="@+id/nlikes"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_weight="50"
app:layout_constraintEnd_toEndOf="@+id/liker"
app:layout_constraintStart_toStartOf="@+id/liker"
app:layout_constraintTop_toBottomOf="@+id/liker"
tools:text="2 Likes" />
<TextView
android:id="@+id/nshares"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_weight="50"
android:gravity="right"
app:layout_constraintEnd_toEndOf="@+id/reblogger"
app:layout_constraintStart_toStartOf="@+id/reblogger"
app:layout_constraintTop_toBottomOf="@+id/reblogger"
tools:text="3 Shares" />
<TextView
android:id="@+id/usernameDesc"
android:layout_height="wrap_content"
android:layout_width="wrap_content"
app:layout_constraintStart_toStartOf="@+id/profilePic"
app:layout_constraintTop_toBottomOf="@+id/nlikes"
tools:text="Account"/>
<TextView
android:id="@+id/description"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:hyphenationFrequency="full"
app:layout_constraintStart_toStartOf="@+id/usernameDesc"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toBottomOf="@+id/usernameDesc"
tools:text="This is a description, describing stuff.\nIt contains multiple lines, and that's okay. It's also got some really long lines, and we love it for it." />
<LinearLayout
android:id="@+id/commentIn"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
app:layout_constraintTop_toBottomOf="@+id/description"
tools:layout_editor_absoluteX="10dp">
<com.google.android.material.textfield.TextInputLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_weight="3">
<EditText
android:id="@+id/editComment"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:hint="Comment"
android:importantForAutofill="no"
android:inputType="text" />
</com.google.android.material.textfield.TextInputLayout>
<ImageButton
android:id="@+id/submitComment"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:layout_gravity="end"
android:layout_weight="1"
android:contentDescription="Submit button"
android:src="@drawable/ic_send_blue" />
</LinearLayout>
<LinearLayout
android:id="@+id/commentContainer"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
tools:layout_editor_absoluteY="315dp"
app:layout_constraintTop_toBottomOf="@+id/commentIn">
<TextView
android:id="@+id/ViewComments"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
app:layout_constraintTop_toTopOf="parent">
android:layout_marginStart="10dp"
android:layout_marginBottom="10dp"
tools:text="TextView"/>
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/constraintLayout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_margin="10dp">
</LinearLayout>
<ImageView
android:id="@+id/profilePic"
android:layout_width="50dp"
android:layout_height="50dp"
android:src="@drawable/ic_default_user"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
<TextView
android:id="@+id/username"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="12dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toEndOf="@+id/profilePic"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_bias="0.516"
tools:text="TextView" />
</androidx.constraintlayout.widget.ConstraintLayout>
</androidx.cardview.widget.CardView>
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/constraintLayout2"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<ImageView
android:id="@+id/postPicture"
android:adjustViewBounds="true"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/LikeShareConstraint"
android:layout_width="match_parent"
android:layout_height="30dp"
android:layout_marginTop="10dp"
>
<LinearLayout
android:layout_width="409dp"
android:layout_height="28dp"
android:orientation="horizontal"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent">
<ImageView
android:id="@+id/liker"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:gravity="start"
android:src="@drawable/ic_heart" />
<ImageView
android:id="@+id/commenter"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:gravity="start"
android:src="@drawable/ic_add_black_24dp" />
</LinearLayout>
</androidx.constraintlayout.widget.ConstraintLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal">
<TextView
android:id="@+id/nlikes"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginLeft="30dp"
android:layout_marginTop="0dp"
android:layout_marginBottom="10dp"
android:layout_weight="50"
tools:text="TextView" />
<TextView
android:id="@+id/nshares"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="0dp"
android:layout_marginRight="30dp"
android:layout_marginBottom="10dp"
android:layout_weight="50"
android:gravity="right"
tools:text="TextView" />
</LinearLayout>
<TextView
android:id="@+id/usernameDesc"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginLeft="10dp"
android:layout_marginTop="10dp"
tools:text="TextView" />
<TextView
android:id="@+id/description"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginLeft="10dp"
android:layout_marginRight="10dp"
android:layout_marginBottom="10dp"
tools:text="TextView" />
<LinearLayout
android:id="@+id/commentIn"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginEnd="10dp"
android:layout_marginStart="10dp"
android:orientation="horizontal">
<com.google.android.material.textfield.TextInputLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_weight="3">
<EditText
android:id="@+id/editComment"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:hint="Comment" />
</com.google.android.material.textfield.TextInputLayout>
<ImageButton
android:id="@+id/submitComment"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:layout_gravity="end"
android:src="@drawable/ic_add_black_24dp"
android:layout_weight="1"
android:contentDescription="Submit button" />
</LinearLayout>
<LinearLayout
android:id="@+id/commentContainer"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<TextView
android:id="@+id/ViewComments"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="10dp"
android:layout_marginBottom="10dp"
tools:text="TextView">
</TextView>
</LinearLayout>
</LinearLayout>
</androidx.constraintlayout.widget.ConstraintLayout>
</androidx.cardview.widget.CardView>
</ScrollView>
</FrameLayout>

View File

@ -1,4 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="auth_scheme" translatable="false">oauth2redirect</string>
<declare-styleable name="SparkButton">
<attr format="dimension|reference" name="iconSize"/>
<attr format="reference" name="activeImage"/>
<attr format="reference" name="inactiveImage"/>
<attr format="reference" name="primaryColor"/>
<attr format="reference" name="secondaryColor"/>
<attr format="float" name="animationSpeed"/>
</declare-styleable>
<color name="heart_red">#c42f0a</color>
<color name="share_blue">#0000ff</color>
<color name="black">#000000</color>
</resources>

View File

@ -32,18 +32,6 @@ class PostUnitTest {
@Test
fun getProfilePicUrlReturnsAValidURL() = Assert.assertNotNull(status.getProfilePicUrl())
@Test
fun getDescriptionReturnsDefaultIfEmpty() {
val emptyDescStatus = status.copy(content = "")
Assert.assertEquals( "No description", emptyDescStatus.getDescription())
}
@Test
fun getDescriptionReturnsAValidDesc() = Assert.assertNotNull(status.getDescription())
@Test
fun getDescriptionReturnsACorrectDesc() = Assert.assertEquals(status.content, status.getDescription())
@Test
fun getUsernameReturnsACorrectName() = Assert.assertEquals(status.account.display_name, status.getUsername())

View File

@ -5,7 +5,6 @@ buildscript {
repositories {
google()
jcenter()
}
dependencies {
classpath 'com.android.tools.build:gradle:3.6.2'
@ -20,7 +19,8 @@ allprojects {
repositories {
google()
jcenter()
maven { url "https://jitpack.io" }
}
}