Feature/post creation (#83)

* added perm and features for cameraS, gps and external storage

* added camera activity accessible from main activity

* added button to redirect to camera activity

* implementing callback flow to use camera

* working camera

* added texture view for camera display

* added camera activity

* implemented texture listener

* camera not working, flow done, no feedback implemented

* camera working

* refactored code, still an activity

* added private to internal function, better error function handling

* deleted camera activity

* added camera fragment

* added camera fragment

* refactored camera as fragment

* necessary dependencies for fragment testing

* initial camera fragment test

* corrected access to activity form fragment

* Added state changes and termination

* added lines to test, to test coverage

* Removed unsupported state STARTED state transition

* Added basic tests to test code coverage

* use layout for tests, to trigger permissions requirements

* grant camera permission to app in camera test

* replaced null handlers by proper function getter

* changed layout, added takePictureButton

* using expresso to get code coverage on camea

* take picture flow not finished

* dummy change to camera test to perform new build

* added connection flow before test to reach main activity

* can take a picture and put it to ImageView

* replaced button text with images

* smaller buttons

* test camera fragment buttons

* added orientation handler

* changed icon to make travis happy

* test new espresso config for travis

* removed useless rule

* deleted useless val

* added layout ID's

* moved swipes from Before to Tests, and thread sleep

* stoped swiping, now tests from fragment directly

* start post creation flow

* use Uri when taking photo, can now go back from picture preview

* adjusted test and flow idea

* tests on displayed UI elements for the post creation fragment

* refactor camera fragment into transition new post fragemnt

* finished first phase: get a picture Uri

* fixed lint error found by travis CI

* added global timeout to test

* test the new way of test

* refactor new way of testing

* added in-app camera view and linked everything to the final flow + started API to post

* strugling on the upload media part

* upload image on server implemented

* post upload implemented

* added API call to get max_toot_chars and correct def of a post description

* fixed some tests

* fix tests: clicking on tabs make the app crash because of the camera fragment

* comment problematic chunk of code while samuel tries to fix it

* switch minimumsdk to api 24

* Revert "switch minimumsdk to api 24"

This reverts commit 24ce46dd82038b59732fd958e5e071ded39cd549.

* deactivited live camera for API 23

* tests for post creation fragment UI elements

* remove worthless UI testing and add gallery intent test

* removed camera intent for now

* some refactor

* lint error and more refactor

* more refactor on merge from master

* refactor and test for PostCreationActivity

* Revert "refactor and test for PostCreationActivity"

This reverts commit a0c146bcc545cdc3792df4806e6b0c908bd18747.

* Revert "Revert "refactor and test for PostCreationActivity""

This reverts commit 147a9ed80d5f9c9e3c38b5a977786bfb39eeb1b6.

* permissions correction for test

* updtated test

* fix a test and refactor

* relink correct fragment

* save picture locally

* test post button

* requested changes

* fixed required changes

* Revert "fixed required changes"

This reverts commit 405a9d4d1af05353e30028e60041cc1c97569c1b.

* redo change request

* added /media api response to mockserver

Co-authored-by: Andrea Clement <samuel.dietz@epfl.ch>
This commit is contained in:
Ulysse Widmer 2020-04-24 12:44:12 +02:00 committed by GitHub
parent 3506f034d4
commit 92c534ca1b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
25 changed files with 643 additions and 86 deletions

View File

@ -99,6 +99,13 @@ dependencies {
implementation 'androidx.paging:paging-runtime-ktx:2.1.2'
implementation 'androidx.lifecycle:lifecycle-livedata-ktx:2.2.0'
androidTestImplementation 'androidx.test:runner:1.2.0'
androidTestImplementation 'androidx.test:rules:1.2.0'
def fragment_version = '1.2.4'
debugImplementation "androidx.fragment:fragment-testing:$fragment_version"
}
tasks.withType(Test) {

View File

@ -8,18 +8,14 @@ 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
@ -28,8 +24,6 @@ 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

View File

@ -15,7 +15,6 @@ 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
import androidx.test.espresso.intent.rule.IntentsTestRule
import androidx.test.espresso.matcher.ViewMatchers.hasErrorText
import androidx.test.espresso.matcher.ViewMatchers.withId
import androidx.test.espresso.matcher.ViewMatchers.withText

View File

@ -29,7 +29,7 @@ import org.junit.runner.RunWith
class MockedServerTest {
private val mockServer = MockServer()
private lateinit var activityScenario: ActivityScenario<MainActivity>
@get:Rule
var globalTimeout: Timeout = Timeout.seconds(100)
@ -45,13 +45,15 @@ class MockedServerTest {
.targetContext.getSharedPreferences("com.h.pixeldroid.pref", Context.MODE_PRIVATE)
preferences.edit().putString("accessToken", "azerty").apply()
preferences.edit().putString("domain", baseUrl.toString()).apply()
activityScenario = ActivityScenario.launch(MainActivity::class.java)
}
@Test
fun testFollowersTextView() {
ActivityScenario.launch(MainActivity::class.java).onActivity{
activityScenario.onActivity{
a -> a.findViewById<TabLayout>(R.id.tabs).getTabAt(4)?.select()
}
Thread.sleep(1000)
onView(withId(R.id.nbFollowersTextView)).check(matches(withText("68\nFollowers")))
onView(withId(R.id.accountNameTextView)).check(matches(withText("deerbard_photo")))
@ -136,29 +138,31 @@ class MockedServerTest {
@Test
fun testNotificationsList() {
ActivityScenario.launch(MainActivity::class.java).onActivity{
activityScenario.onActivity{
a -> a.findViewById<TabLayout>(R.id.tabs).getTabAt(3)?.select()
}
Thread.sleep(1000)
Thread.sleep(1000)
onView(withId(R.id.view_pager)).perform(ViewActions.swipeUp()).perform(ViewActions.swipeDown())
onView(withText("Dobios liked your post")).check(matches(withId(R.id.notification_type)))
onView(withId(R.id.view_pager)).perform(ViewActions.swipeDown())
Thread.sleep(1000)
onView(withText("Dobios followed you")).check(matches(withId(R.id.notification_type)))
}
@Test
fun clickNotification() {
ActivityScenario.launch(MainActivity::class.java).onActivity{
activityScenario.onActivity{
a -> a.findViewById<TabLayout>(R.id.tabs).getTabAt(3)?.select()
}
Thread.sleep(1000)
Thread.sleep(1000)
onView(withId(R.id.view_pager)).perform(ViewActions.swipeUp()).perform(ViewActions.swipeDown())
Thread.sleep(1000)
Thread.sleep(1000)
onView(withText("Dobios liked your post")).perform(ViewActions.click())
Thread.sleep(1000)
onView(withText("6 Likes")).check(matches(withId(R.id.nlikes)))
}
@ -209,9 +213,11 @@ class MockedServerTest {
@Test
fun swipingRightStopsAtHomepage() {
ActivityScenario.launch(MainActivity::class.java).onActivity {
activityScenario.onActivity {
a -> a.findViewById<TabLayout>(R.id.tabs).getTabAt(4)?.select()
} // go to the last tab
Thread.sleep(1000)
onView(withId(R.id.main_activity_main_linear_layout))
.perform(ViewActions.swipeRight()) // notifications
.perform(ViewActions.swipeRight()) // camera
@ -445,5 +451,10 @@ class MockedServerTest {
onView(first(withId(R.id.commentContainer)))
.check(matches(hasDescendant(withId(R.id.comment))))
}
@Test
fun instanceConfigurationTest() {
}
}

View File

@ -0,0 +1,51 @@
package com.h.pixeldroid
import android.content.Context
import android.content.Intent
import android.net.Uri
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.matcher.ViewMatchers.isDisplayed
import androidx.test.espresso.matcher.ViewMatchers.withId
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import com.h.pixeldroid.testUtility.MockServer
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)
class PostCreationActivityTest {
val mockServer = MockServer()
@get:Rule
val globalTimeout: Timeout = Timeout.seconds(30)
@Before
fun setup() {
val context = InstrumentationRegistry.getInstrumentation().targetContext
mockServer.start()
val baseUrl = mockServer.getUrl()
val preferences = context.getSharedPreferences("com.h.pixeldroid.pref", Context.MODE_PRIVATE)
preferences.edit().putString("accessToken", "azerty").apply()
preferences.edit().putString("domain", baseUrl.toString()).apply()
val uri: Uri = Uri.parse("android.resource://com.h.pixeldroid/drawable/index")
val intent = Intent(context, PostCreationActivity::class.java)
.putExtra("picture_uri", uri)
ActivityScenario.launch<PostCreationActivity>(intent)
}
@Test
fun createPost() {
onView(withId(R.id.post_creation_send_button)).perform(click())
// should send on main activity
onView(withId(R.id.main_activity_main_linear_layout)).check(matches(isDisplayed()))
}
}

View File

@ -0,0 +1,88 @@
package com.h.pixeldroid
import android.content.Context
import android.content.Intent
import androidx.test.core.app.ActivityScenario
import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.action.ViewActions.*
import androidx.test.espresso.assertion.ViewAssertions.matches
import androidx.test.espresso.intent.Intents.intended
import androidx.test.espresso.intent.Intents.intending
import androidx.test.espresso.intent.matcher.IntentMatchers.hasAction
import androidx.test.espresso.intent.rule.IntentsTestRule
import androidx.test.espresso.matcher.ViewMatchers.isDisplayed
import androidx.test.espresso.matcher.ViewMatchers.withId
import androidx.test.ext.junit.rules.ActivityScenarioRule
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.rule.GrantPermissionRule
import com.h.pixeldroid.testUtility.MockServer
import kotlinx.android.synthetic.main.activity_main.*
import org.hamcrest.Matcher
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)
class PostCreationFragmentTest {
@get:Rule
var globalTimeout: Timeout = Timeout.seconds(30)
@get:Rule
var runtimePermissionRule: GrantPermissionRule =
GrantPermissionRule.grant(android.Manifest.permission.WRITE_EXTERNAL_STORAGE)
@get:Rule
var intentsTestRule: IntentsTestRule<MainActivity> =
IntentsTestRule(MainActivity::class.java)
@Before
fun setup() {
onView(withId(R.id.main_activity_main_linear_layout))
.perform(swipeLeft())
.perform(swipeLeft())
Thread.sleep(300)
}
// upload intent
@Test
fun uploadButtonLaunchesGalleryIntent() {
val expectedIntent: Matcher<Intent> = hasAction(Intent.ACTION_CHOOSER)
intending(expectedIntent)
onView(withId(R.id.uploadPictureButton)).perform(click())
Thread.sleep(1000)
intended(expectedIntent)
}
}
@RunWith(AndroidJUnit4::class)
class PostFragmentUITests {
private val mockServer = MockServer()
@get:Rule
var globalTimeout: Timeout = Timeout.seconds(30)
@get:Rule
var rule = ActivityScenarioRule(MainActivity::class.java)
@Before
fun setup() {
mockServer.start()
val baseUrl = mockServer.getUrl()
val preferences = InstrumentationRegistry.getInstrumentation()
.targetContext.getSharedPreferences("com.h.pixeldroid.pref", Context.MODE_PRIVATE)
preferences.edit().putString("accessToken", "azerty").apply()
preferences.edit().putString("domain", baseUrl.toString()).apply()
ActivityScenario.launch(MainActivity::class.java).onActivity {
a -> a.tabs.getTabAt(2)!!.select()
}
Thread.sleep(300)
}
@Test
fun newPostUiTest() {
onView(withId(R.id.uploadPictureButton)).check(matches(isDisplayed()))
onView(withId(R.id.takePictureButton)).check(matches(isDisplayed()))
}
}

View File

@ -127,6 +127,36 @@ class MockServer {
val reblogJson = """{"id":"156491373246287872","created_at":"2020-04-16T20:00:50.000000Z","in_reply_to_id":null,"in_reply_to_account_id":null,"sensitive":false,"spoiler_text":"","visibility":"public","language":"en","uri":"https:\/\/pixelfed.de\/p\/machintuck\/156491373246287872","url":"https:\/\/pixelfed.de\/p\/machintuck\/156491373246287872","replies_count":1,"reblogs_count":14,"favourites_count":2,"reblogged":true,"favourited":false,"muted":false,"bookmarked":false,"pinned":false,"content":"<a class=\"u-url mention\" href=\"https:\/\/pixelfed.de\/Dobios\" rel=\"external nofollow noopener\">@Dobios<\/a> <a class=\"u-url mention\" href=\"https:\/\/pixelfed.de\/Dante\" rel=\"external nofollow noopener\">@Dante<\/a>","reblog":null,"application":{"name":"web","website":null},"mentions":[{"id":"136800034732773376","url":"https:\/\/pixelfed.de\/Dobios","username":"Dobios","acct":"Dobios"},{"id":"136453537340198912","url":"https:\/\/pixelfed.de\/dante","username":"dante","acct":"dante"}],"tags":[{"name":"mushroom","url":"https:\/\/pixelfed.de\/discover\/tags\/mushroom"},{"name":"commentsstillbroken","url":"https:\/\/pixelfed.de\/discover\/tags\/commentsstillbroken"},{"name":"fixyourapi","url":"https:\/\/pixelfed.de\/discover\/tags\/fixyourapi"},{"name":"pls","url":"https:\/\/pixelfed.de\/discover\/tags\/pls"}],"emojis":[],"card":null,"poll":null,"account":{"id":"145183325781364736","username":"machintuck","acct":"machintuck","display_name":"Arthur","locked":false,"created_at":"2020-03-16T15:06:42.000000Z","followers_count":4,"following_count":4,"statuses_count":5,"note":"","url":"https:\/\/pixelfed.de\/machintuck","avatar":"https:\/\/pixelfed.de\/storage\/avatars\/014\/518\/332\/578\/136\/473\/6\/gbdKtKOhTkNA5UxCzeAQ_avatar.jpeg?v=d4735e3a265e16eee03f59718b9b5d03019c07d8b6c51f90da3a666eec13ab35","avatar_static":"https:\/\/pixelfed.de\/storage\/avatars\/014\/518\/332\/578\/136\/473\/6\/gbdKtKOhTkNA5UxCzeAQ_avatar.jpeg?v=d4735e3a265e16eee03f59718b9b5d03019c07d8b6c51f90da3a666eec13ab35","header":"","header_static":"","emojis":[],"moved":null,"fields":null,"bot":false,"software":"pixelfed","is_admin":false},"media_attachments":[{"id":"19228","type":"image","url":"https:\/\/pixelfed.de\/storage\/m\/d0931bf747b992a1c83e055753526516f2706111\/9b4393bfd32c643a265bd1c557b981f167d60969\/lbOqQOMeHLGmhYgehhZUBJ4JvjtKulh83BA97LoP.jpeg","remote_url":null,"preview_url":"https:\/\/pixelfed.de\/storage\/m\/d0931bf747b992a1c83e055753526516f2706111\/9b4393bfd32c643a265bd1c557b981f167d60969\/lbOqQOMeHLGmhYgehhZUBJ4JvjtKulh83BA97LoP_thumb.jpeg","text_url":null,"meta":null,"description":null}]}"""
val mediaUploadResponseJson = """
{
"id": "22348641",
"type": "image",
"url": "https://files.mastodon.social/media_attachments/files/022/348/641/original/cebc6d51be03e509.jpeg",
"preview_url": "https://files.mastodon.social/media_attachments/files/022/348/641/small/cebc6d51be03e509.jpeg",
"remote_url": null,
"text_url": "https://mastodon.social/media/4Zj6ewxzzzDi0g8JnZQ",
"meta": {
"focus": {
"x": -0.69,
"y": 0.42
},
"original": {
"width": 640,
"height": 480,
"size": "640x480",
"aspect": 1.3333333333333333
},
"small": {
"width": 461,
"height": 346,
"size": "461x346",
"aspect": 1.3323699421965318
}
},
"description": "test uploaded via api",
"blurhash": "UFBWY:8_0Jxv4mx]t8t64.%M-:IUWGWAt6M}"
}
"""
val followRelationshipJson = """{"id":"136800034732773376","following":true,"followed_by":true,"blocking":false,"muting":false,"muting_notifications":null,"requested":false,"domain_blocking":null,"showing_reblogs":null,"endorsed":false}"""
val unfollowRelationshipJson = """{"id":"136800034732773376","following":false,"followed_by":true,"blocking":false,"muting":false,"muting_notifications":null,"requested":false,"domain_blocking":null,"showing_reblogs":null,"endorsed":false}"""
val relationshipJson = """[{"id":"136800034732773376","following":true,"followed_by":true,"blocking":false,"muting":false,"muting_notifications":null,"requested":false,"domain_blocking":null,"showing_reblogs":null,"endorsed":false}]"""
@ -145,6 +175,7 @@ class MockServer {
when (request.path) {
"/api/v1/accounts/verify_credentials" -> return MockResponse().addHeader("Content-Type", "application/json; charset=utf-8").setResponseCode(200).setBody(accountJson)
"/api/v1/timelines/home" -> return MockResponse().addHeader("Content-Type", "application/json; charset=utf-8").setResponseCode(200).setBody(feedJson)
"/api/v1/media" -> return MockResponse().addHeader("Content-Type", "application/json; charset=utf-8").setResponseCode(200).setBody(mediaUploadResponseJson)
}
when {
request.path?.startsWith("/api/v1/notifications") == true -> {

View File

@ -3,7 +3,13 @@
package="com.h.pixeldroid">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-feature
android:name="android.hardware.camera.any"
android:required="false" />
<uses-feature android:name="android.hardware.location.gps" />
<application
android:allowBackup="true"
@ -13,6 +19,7 @@
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/AppTheme">
<activity android:name=".PostCreationActivity" />
<activity android:name=".FollowsActivity" />
<activity android:name=".PostActivity" />
<activity android:name=".ProfileActivity" />
@ -47,6 +54,16 @@
android:scheme="@string/auth_scheme" />
</intent-filter>
</activity>
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="com.h.pixeldroid.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
</application>
</manifest>

View File

@ -6,12 +6,14 @@ import android.content.Intent
import android.content.SharedPreferences
import android.net.Uri
import android.os.Bundle
import android.util.Log
import android.view.View
import android.view.inputmethod.InputMethodManager
import androidx.appcompat.app.AppCompatActivity
import androidx.browser.customtabs.CustomTabsIntent
import com.h.pixeldroid.api.PixelfedAPI
import com.h.pixeldroid.objects.Application
import com.h.pixeldroid.objects.Instance
import com.h.pixeldroid.objects.Token
import kotlinx.android.synthetic.main.activity_login.*
import okhttp3.HttpUrl
@ -22,6 +24,8 @@ import retrofit2.Response
class LoginActivity : AppCompatActivity() {
private val TAG = "Login Activity"
private lateinit var OAUTH_SCHEME: String
private val PACKAGE_ID = BuildConfig.APPLICATION_ID
private val SCOPE = "read write follow"
@ -81,6 +85,7 @@ class LoginActivity : AppCompatActivity() {
preferences.edit()
.putString("domain", "https://$normalizedDomain")
.apply()
getInstanceConfig()
registerAppToServer("https://$normalizedDomain")
}
@ -174,7 +179,7 @@ class LoginActivity : AppCompatActivity() {
if (!response.isSuccessful || response.body() == null) {
return failedRegistration(getString(R.string.token_error))
}
authenticationSuccessful(domain, response.body()!!.access_token)
authenticationSuccessful(response.body()!!.access_token)
}
override fun onFailure(call: Call<Token>, t: Throwable) {
@ -189,7 +194,7 @@ class LoginActivity : AppCompatActivity() {
).enqueue(callback)
}
private fun authenticationSuccessful(domain: String, accessToken: String) {
private fun authenticationSuccessful(accessToken: String) {
preferences.edit().putString("accessToken", accessToken).apply()
val intent = Intent(this, MainActivity::class.java)
startActivity(intent)
@ -213,4 +218,30 @@ class LoginActivity : AppCompatActivity() {
}
}
private fun getInstanceConfig() {
// to get max post description length, can be enhanced for other things
// see /api/v1/instance
PixelfedAPI.create(preferences.getString("domain", "")!!)
.instance().enqueue(object : Callback<Instance> {
override fun onFailure(call: Call<Instance>, t: Throwable) {
Log.e(TAG, "Request to fetch instance config failed.")
preferences.edit().putInt("max_toot_chars", 500).apply()
}
override fun onResponse(call: Call<Instance>, response: Response<Instance>) {
if (response.code() == 200) {
preferences.edit().putInt(
"max_toot_chars",
response.body()!!.max_toot_chars.toInt()
).apply()
} else {
Log.e(TAG, "Server response to fetch instance config failed.")
preferences.edit().putInt("max_toot_chars", 500).apply()
}
}
})
}
}

View File

@ -15,7 +15,7 @@ import androidx.viewpager2.widget.ViewPager2
import com.google.android.material.navigation.NavigationView
import com.google.android.material.tabs.TabLayout
import com.google.android.material.tabs.TabLayoutMediator
import com.h.pixeldroid.fragments.CameraFragment
import com.h.pixeldroid.fragments.NewPostFragment
import com.h.pixeldroid.fragments.feeds.HomeFragment
import com.h.pixeldroid.fragments.ProfileFragment
import com.h.pixeldroid.fragments.feeds.NotificationsFragment
@ -49,7 +49,7 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte
val tabs = arrayOf(
HomeFragment(),
Fragment(),
CameraFragment(),
NewPostFragment(),
NotificationsFragment(),
ProfileFragment()
)

View File

@ -0,0 +1,162 @@
package com.h.pixeldroid
import android.content.Context
import android.content.Intent
import android.content.SharedPreferences
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.net.Uri
import android.os.Bundle
import android.os.Environment
import android.util.Log
import android.widget.Button
import android.widget.ImageView
import android.widget.Toast
import androidx.appcompat.app.AppCompatActivity
import androidx.core.net.toUri
import com.google.android.material.textfield.TextInputEditText
import com.h.pixeldroid.api.PixelfedAPI
import com.h.pixeldroid.objects.Attachment
import com.h.pixeldroid.objects.Status
import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.MultipartBody
import okhttp3.RequestBody
import okhttp3.RequestBody.Companion.asRequestBody
import retrofit2.Call
import retrofit2.Callback
import retrofit2.Response
import java.io.ByteArrayOutputStream
import java.io.File
import java.io.FileOutputStream
import java.io.IOException
import java.text.SimpleDateFormat
import java.util.Date
class PostCreationActivity : AppCompatActivity() {
private val TAG = "Post Creation Activity"
private lateinit var accessToken: String
private lateinit var pixelfedAPI: PixelfedAPI
private lateinit var preferences: SharedPreferences
private lateinit var pictureFrame: ImageView
private lateinit var image: File
private var description: String = ""
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_post_creation)
val imageUri: Uri = intent.getParcelableExtra<Uri>("picture_uri")!!
saveImage(imageUri)
pictureFrame = findViewById<ImageView>(R.id.post_creation_picture_frame)
pictureFrame.setImageURI(image.toUri())
preferences = getSharedPreferences(
"${BuildConfig.APPLICATION_ID}.pref", Context.MODE_PRIVATE
)
pixelfedAPI = PixelfedAPI.create("${preferences.getString("domain", "")}")
accessToken = preferences.getString("accessToken", "")!!
// check if the picture is alright
// TODO
// edit the picture
// TODO
// get the description and send the post to PixelFed
findViewById<Button>(R.id.post_creation_send_button).setOnClickListener {
if (setDescription()) upload()
}
}
private fun saveImage(imageUri: Uri) {
val timeStamp: String = SimpleDateFormat("yyyyMMdd_HHmmss").format(Date())
val fileName = "PixelDroid_$timeStamp.png"
try {
val stream = applicationContext.contentResolver
.openAssetFileDescriptor(imageUri, "r")!!
.createInputStream()
val bm = BitmapFactory.decodeStream(stream)
val bos = ByteArrayOutputStream()
bm.compress(Bitmap.CompressFormat.PNG, 0, bos)
image = File(
applicationContext.getExternalFilesDir(Environment.DIRECTORY_PICTURES),
fileName)
val fos = FileOutputStream(image)
fos.write(bos.toByteArray())
fos.flush()
fos.close()
} catch (error: IOException) {
error.printStackTrace()
throw error
}
}
private fun setDescription(): Boolean {
val textField = findViewById<TextInputEditText>(R.id.new_post_description_input_field)
val content = textField.text.toString()
val maxLength = preferences.getInt("max_toot_chars", 500)
if (content.length > maxLength) {
// error, too much characters
textField.error = "Description must contain $maxLength characters at most."
return false
}
// store the description
description = content
return true
}
private fun upload() {
val rBody: RequestBody = image.asRequestBody("image/*".toMediaTypeOrNull())
val part = MultipartBody.Part.createFormData("file", image.name, rBody)
pixelfedAPI.mediaUpload("Bearer $accessToken", part).enqueue(object:
Callback<Attachment> {
override fun onFailure(call: Call<Attachment>, t: Throwable) {
Log.e(TAG, t.toString() + call.request())
Toast.makeText(applicationContext,"Picture upload error!",Toast.LENGTH_SHORT).show()
}
override fun onResponse(call: Call<Attachment>, response: Response<Attachment>) {
if (response.code() == 200) {
val body = response.body()!!
if (body.type.name == "image") {
post(body.id)
} else
Toast.makeText(applicationContext, "Upload error: wrong picture format.", Toast.LENGTH_SHORT).show()
} else {
Log.e(TAG, "Server responded: $response" + call.request() + call.request().body)
Toast.makeText(applicationContext,"Upload error: bad request format",Toast.LENGTH_SHORT).show()
}
}
})
}
private fun post(id: String) {
if (id.isEmpty()) return
pixelfedAPI.postStatus(
authorization = "Bearer $accessToken",
statusText = description,
media_ids = listOf(id)
).enqueue(object: Callback<Status> {
override fun onFailure(call: Call<Status>, t: Throwable) {
Toast.makeText(applicationContext,"Post upload failed",Toast.LENGTH_SHORT).show()
Log.e(TAG, t.message + call.request())
}
override fun onResponse(call: Call<Status>, response: Response<Status>) {
if (response.code() == 200) {
Toast.makeText(applicationContext,"Post upload success",Toast.LENGTH_SHORT).show()
startActivity(Intent(applicationContext, MainActivity::class.java))
} else {
Toast.makeText(applicationContext,"Post upload failed : not 200",Toast.LENGTH_SHORT).show()
Log.e(TAG, call.request().toString() + response.raw().toString())
}
}
})
}
}

View File

@ -1,6 +1,7 @@
package com.h.pixeldroid.api
import com.h.pixeldroid.objects.*
import okhttp3.MultipartBody
import retrofit2.Call
import retrofit2.Retrofit
import retrofit2.adapter.rxjava2.RxJava2CallAdapterFactory
@ -8,7 +9,6 @@ import retrofit2.converter.gson.GsonConverterFactory
import retrofit2.http.*
import retrofit2.http.Field
/*
Implements the Pixelfed API
https://docs.pixelfed.org/technical-documentation/api-v1.html
@ -74,14 +74,14 @@ interface PixelfedAPI {
@Path("id") statusId: String
) : Call<List<Account>>
//Used in our case to post a comment
//Used in our case to post a comment or a status
@FormUrlEncoded
@POST("/api/v1/statuses")
fun postStatus(
//The authorization header needs to be of the form "Bearer <token>"
@Header("Authorization") authorization: String,
@Field("status") statusText : String,
@Field("in_reply_to_id") in_reply_to_id : String,
@Field("in_reply_to_id") in_reply_to_id : String? = null,
@Field("media_ids[]") media_ids : List<String> = emptyList(),
@Field("poll[options][]") poll_options : List<String>? = null,
@Field("poll[expires_in]") poll_expires : List<String>? = null,
@ -203,5 +203,17 @@ interface PixelfedAPI {
.build().create(PixelfedAPI::class.java)
}
}
@Multipart
@POST("/api/v1/media")
fun mediaUpload(
//The authorization header needs to be of the form "Bearer <token>"
@Header("Authorization") authorization: String,
@Part file: MultipartBody.Part
): Call<Attachment>
// get instance configuration
@GET("/api/v1/instance")
fun instance() : Call<Instance>
}

View File

@ -22,10 +22,8 @@ abstract class AppDatabase : RoomDatabase() {
fun getDatabase(context: Context): AppDatabase {
return INSTANCE ?: synchronized(this) {
var instance: AppDatabase? = null
// To be able to create a temporary database that flushes when tests are over
instance = if (TEST_MODE) {
var instance = if (TEST_MODE) {
Room.inMemoryDatabaseBuilder(context, AppDatabase::class.java).allowMainThreadQueries().build()
} else {
Room.databaseBuilder(

View File

@ -3,54 +3,57 @@ package com.h.pixeldroid.fragments
import android.app.Activity
import android.content.Intent
import android.os.Bundle
import android.util.Log
import androidx.fragment.app.Fragment
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.Button
import android.widget.ImageView
import androidx.fragment.app.Fragment
import com.h.pixeldroid.PostCreationActivity
import com.h.pixeldroid.R
class CameraFragment : Fragment() {
/**
* This fragment is the entry point to create a post.
* You can either upload an existing picture or take a new one.
* once the URI of the picture to be posted is set, it will send
* it to the post creation activity where you can modify it,
* add a description and more.
*/
class NewPostFragment : Fragment() {
private val PICK_IMAGE_REQUEST = 1
private val TAG = "Camera Fragment"
private var uploadedPictureView: ImageView? = null
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
val view = inflater.inflate(R.layout.fragment_camera, container, false)
val uploadPictureButton: Button = view.findViewById(R.id.upload_picture_button)
uploadedPictureView = view.findViewById(R.id.uploaded_picture_view)
val view = inflater.inflate(R.layout.fragment_new_post, container, false)
val uploadPictureButton: Button = view.findViewById(R.id.uploadPictureButton)
uploadPictureButton.setOnClickListener{
uploadPicture()
}
// Inflate the layout for this fragment
return view
}
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
if (resultCode == Activity.RESULT_OK && data != null
&& requestCode == PICK_IMAGE_REQUEST && data.data != null)
startActivity(Intent(activity, PostCreationActivity::class.java)
.putExtra("picture_uri", data.data)
)
}
private fun uploadPicture() {
Intent().apply {
type = "image/*"
action = Intent.ACTION_GET_CONTENT
addCategory(Intent.CATEGORY_OPENABLE)
putExtra(Intent.EXTRA_LOCAL_ONLY, true)
startActivityForResult(
Intent.createChooser(this, "Select a Picture"), PICK_IMAGE_REQUEST
)
}
}
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
if (requestCode == PICK_IMAGE_REQUEST && resultCode == Activity.RESULT_OK) {
if(data == null || data.data == null){
Log.w(TAG, "No picture uploaded")
return
}
uploadedPictureView?.setImageURI(data.data)
}
}
}

View File

@ -15,7 +15,6 @@ import com.h.pixeldroid.api.PixelfedAPI
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.*
class PostFragment : Fragment() {

View File

@ -0,0 +1,12 @@
package com.h.pixeldroid.objects
data class Instance (
val description: String,
val email: String,
val max_toot_chars: String = "500",
val registrations: Boolean,
val thumbnail: String,
val title: String,
val uri: String,
val version: String
)

View File

@ -107,12 +107,12 @@ data class Status(
}
fun getNLikes() : CharSequence {
val nLikes : Int = favourites_count ?: 0
val nLikes = favourites_count
return "$nLikes Likes"
}
fun getNShares() : CharSequence {
val nShares : Int = reblogs_count ?: 0
val nShares = reblogs_count
return "$nShares Shares"
}
@ -257,7 +257,7 @@ data class Status(
if (replies_count == 0) {
holder.viewComment.text = "No comments on this post..."
} else {
holder.viewComment.text = "View all ${replies_count} comments..."
holder.viewComment.text = "View all $replies_count comments..."
holder.viewComment.setOnClickListener {
holder.viewComment.visibility = View.GONE

View File

@ -1,5 +1,4 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="356.388dp"
android:height="280dp"
android:viewportWidth="356.388"

View File

@ -0,0 +1,57 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout 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:id="@+id/camera_fragment_main_linear_layout"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".fragments.NewPostFragment">
<TextureView
android:id="@+id/textureView"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="1.0"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_bias="0.0" />
<Button
android:id="@+id/takePictureButton"
android:layout_width="36dp"
android:layout_height="30dp"
android:layout_margin="15dp"
android:layout_marginStart="14dp"
android:background="?android:attr/listChoiceIndicatorSingle"
android:gravity="center"
android:padding="15dp"
android:textColor="@color/cardview_light_background"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.517"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_bias="1.0"
tools:ignore="MissingConstraints,PrivateResource" />
<ImageView
android:id="@+id/uploadedPictureView"
android:layout_width="366dp"
android:layout_height="532dp"
android:layout_margin="15dp"
android:layout_marginStart="14dp"
android:layout_marginEnd="14dp"
android:contentDescription="@string/upload_a_picture"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@+id/textureView"
app:layout_constraintHorizontal_bias="0.501"
app:layout_constraintStart_toEndOf="@+id/textureView"
app:layout_constraintTop_toTopOf="@+id/textureView"
app:layout_constraintVertical_bias="0.147"
tools:ignore="MissingConstraints"
tools:srcCompat="@tools:sample/avatars" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -0,0 +1,55 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:android="http://schemas.android.com/apk/res/android">
<ImageView
android:id="@+id/post_creation_picture_frame"
android:layout_width="match_parent"
android:layout_height="match_parent"/>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:layout_gravity="bottom"
android:background="#88000000">
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/new_post_description_input_layout"
android:layout_width="0dp"
android:layout_weight="1"
android:layout_height="wrap_content"
android:hint="@string/description"
app:errorEnabled="true"
android:layout_gravity="fill_horizontal"
android:paddingStart="15dp"
android:textColorHint="@color/cardview_light_background"
app:errorTextColor="@color/cardview_light_background">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/new_post_description_input_field"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:ems="10"
android:inputType="textMultiLine"
android:textColor="@color/cardview_light_background"/>
</com.google.android.material.textfield.TextInputLayout>
<Button
android:id="@+id/post_creation_send_button"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/send"
android:background="@color/colorPrimary"
android:textColor="@color/cardview_light_background"
android:layout_margin="15dp"
android:layout_gravity="center_vertical"
tools:ignore="PrivateResource"/>
</LinearLayout>
</FrameLayout>

View File

@ -1,29 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/camera_fragment_main_linear_layout"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:gravity="center_horizontal"
tools:context=".fragments.CameraFragment"
android:orientation="vertical">
<Button
android:id="@+id/upload_picture_button"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:gravity="center"
android:padding="15dp"
android:layout_margin="15dp"
android:text="@string/upload_a_picture"
android:background="@color/colorPrimary"
android:textColor="@color/cardview_light_background"/>
<ImageView
android:id="@+id/uploaded_picture_view"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_margin="15dp"
tools:srcCompat="@tools:sample/avatars" />
</LinearLayout>

View File

@ -0,0 +1,48 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_height="match_parent"
android:layout_width="match_parent"
android:orientation="vertical"
android:gravity="center">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/create_a_new_post"
android:textColor="@color/colorPrimary"
android:textSize="25sp"
android:layout_marginBottom="50dp"/>
<Button
android:id="@+id/uploadPictureButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_margin="15dp"
android:gravity="center"
android:padding="15dp"
android:text="@string/upload_a_picture"
android:background="@color/colorPrimary"
android:textColor="@color/cardview_light_background"
tools:ignore="PrivateResource" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/or"/>
<Button
android:id="@+id/takePictureButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_margin="15dp"
android:gravity="center"
android:padding="15dp"
android:text="@string/take_a_picture"
android:background="@color/colorPrimary"
android:textColor="@color/cardview_light_background"
tools:ignore="PrivateResource" />
</LinearLayout>

View File

@ -54,15 +54,23 @@
</string>
<string name="attachment_summary_off">Only download attachments when manually requested</string>
<!-- TODO: Remove or change this placeholder text -->
<string name="hello_blank_fragment">Hello blank fragment</string>
<string name="start_login">Start Login</string>
<string name="no_username">No Username</string>
<string name="upload_a_picture">Upload a picture</string>
<string name="followed_notification">%1$s followed you</string>
<string name="mention_notification">%1$s mentioned you</string>
<string name="shared_notification">%1$s shared your post</string>
<string name="liked_notification">%1$s liked your post</string>
<string name="create_a_new_post">Create a new post!</string>
<string name="take_a_picture">Take a picture</string>
<string name="upload_a_picture">Upload a picture</string>
<string name="or">or</string>
<string name="description">Description…</string>
<string name="upload">upload</string>
<string name="send">send</string>
<string name="reconnect">Reconnect</string>
<string name="whats_an_instance">What\'s an instance?</string>
<string name="logout">Logout</string>

View File

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<paths>
<external-files-path name="my_images" path="Android/data/com.h.pixeldroid/files/Pictures" />
</paths>

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