Unverified Commit 34f3d12d authored by Ulysse Widmer's avatar Ulysse Widmer Committed by GitHub

Offline mode part 2 - Store posts in the DB (#209)

* store posts base idea

* switch to nullable types in Status object

* store posts first try + switch to nullable types for Attachment objects

* fix some tests, add converters

* update gradle

* wip: display stored post

* first draft of functional offline post

* added likes and shares to offline data

* fully functional

* clear activity correctly

* clear correctly activities

* refactored some tests and added offline feed test

* Distinguish between users, and only store home timeline

* count better

* Sort when getting statuses

* disable buttons, since we're offline anyways
Co-authored-by: default avatarMatthieu <61561059+Wv5twkFEKh54vo4tta9yu7dHa3@users.noreply.github.com>
parent 46498b4a
......@@ -56,15 +56,15 @@ dependencies {
implementation fileTree(dir: 'libs', include: ['*.jar'])
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
implementation 'androidx.appcompat:appcompat:1.1.0'
implementation 'androidx.core:core-ktx:1.2.0'
implementation 'androidx.core:core-ktx:1.3.0'
implementation 'androidx.preference:preference:1.1.1'
implementation 'androidx.constraintlayout:constraintlayout:1.1.3'
implementation 'androidx.navigation:navigation-fragment:2.2.2'
implementation 'androidx.navigation:navigation-ui:2.2.2'
implementation 'com.squareup.okhttp3:okhttp:4.6.0'
implementation 'com.squareup.retrofit2:retrofit:2.8.1'
implementation 'com.squareup.retrofit2:converter-gson:2.8.1'
implementation 'com.squareup.retrofit2:adapter-rxjava2:2.8.1'
implementation 'com.squareup.okhttp3:okhttp:4.7.2'
implementation 'com.squareup.retrofit2:retrofit:2.9.0'
implementation 'com.squareup.retrofit2:converter-gson:2.9.0'
implementation 'com.squareup.retrofit2:adapter-rxjava2:2.9.0'
implementation 'io.reactivex.rxjava2:rxjava:2.2.17'
implementation 'io.reactivex.rxjava2:rxandroid:2.1.1'
implementation "androidx.browser:browser:1.2.0"
......@@ -101,7 +101,7 @@ dependencies {
testImplementation "com.nhaarman.mockitokotlin2:mockito-kotlin:2.2.0"
testImplementation 'junit:junit:4.13'
androidTestImplementation('com.squareup.okhttp3:mockwebserver:4.6.0')
androidTestImplementation('com.squareup.okhttp3:mockwebserver:4.7.2')
androidTestImplementation 'androidx.test.ext:junit:1.1.1'
androidTestImplementation 'androidx.test:runner:1.2.0'
androidTestImplementation 'androidx.test:rules:1.2.0'
......@@ -134,14 +134,14 @@ dependencies {
debugImplementation "androidx.fragment:fragment-testing:$fragment_version"
// Use the most recent version of CameraX
def camerax_version = '1.0.0-beta03'
def camerax_version = '1.0.0-beta04'
implementation "androidx.camera:camera-core:${camerax_version}"
implementation "androidx.camera:camera-camera2:${camerax_version}"
// CameraX Lifecycle library
implementation "androidx.camera:camera-lifecycle:$camerax_version"
// CameraX View class
implementation 'androidx.camera:camera-view:1.0.0-alpha10'
implementation 'androidx.camera:camera-view:1.0.0-alpha11'
implementation 'com.karumi:dexter:6.1.2'
......
This diff is collapsed.
......@@ -4,6 +4,7 @@ import android.content.Context
import androidx.test.core.app.ActivityScenario
import androidx.test.core.app.ApplicationProvider
import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.action.ViewActions.click
import androidx.test.espresso.assertion.ViewAssertions.matches
import androidx.test.espresso.matcher.ViewMatchers.isDisplayed
import androidx.test.espresso.matcher.ViewMatchers.withId
......@@ -24,35 +25,43 @@ import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class LoginActivityOfflineTest {
companion object {
fun switchAirplaneMode() {
val device = UiDevice.getInstance(getInstrumentation())
device.openQuickSettings()
device.findObject(UiSelector().textContains("airplane")).click()
device.pressHome()
}
}
private lateinit var db: AppDatabase
private lateinit var device: UiDevice
@get:Rule
var globalTimeout: Timeout = Timeout.seconds(100)
@Before
fun before() {
device = UiDevice.getInstance(getInstrumentation())
device.openQuickSettings()
device.findObject(UiSelector().textContains("airplane")).click()
device.pressHome()
switchAirplaneMode()
val context = ApplicationProvider.getApplicationContext<Context>()
db = DBUtils.initDB(context)
db.clearAllTables()
db.close()
ActivityScenario.launch(LoginActivity::class.java)
}
@Test
fun emptyDBandOfflineModeDisplayCorrectMessage() {
ActivityScenario.launch(LoginActivity::class.java)
onView(withId(R.id.login_activity_connection_required_text)).check(matches(isDisplayed()))
onView(withId(R.id.login_activity_connection_required)).check(matches(isDisplayed()))
}
@Test
fun retryButtonReloadsLoginActivity() {
onView(withId(R.id.login_activity_connection_required_button)).perform(click())
onView(withId(R.id.login_activity_connection_required)).check(matches(isDisplayed()))
}
@After
fun after() {
device.openQuickSettings()
device.findObject(UiSelector().textContains("airplane")).click()
device.pressHome()
switchAirplaneMode()
db.close()
}
}
\ No newline at end of file
......@@ -4,6 +4,7 @@ import android.content.ActivityNotFoundException
import android.content.Context
import android.content.Intent
import android.content.SharedPreferences
import android.net.ConnectivityManager
import android.net.Uri
import android.os.Bundle
import android.view.View
......@@ -58,12 +59,16 @@ class LoginActivity : AppCompatActivity() {
whatsAnInstanceTextView.setOnClickListener{ whatsAnInstance() }
inputVisibility = View.VISIBLE
} else {
login_activity_connection_required_text.visibility = View.VISIBLE
login_activity_connection_required.visibility = View.VISIBLE
login_activity_connection_required_button.setOnClickListener {
finish();
startActivity(intent);
}
}
loadingAnimation(false)
}
override fun onStart(){
override fun onStart() {
super.onStart()
val url: Uri? = intent.data
......
......@@ -160,7 +160,7 @@ class PostCreationActivity : AppCompatActivity(){
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe({ attachment ->
listOfIds = listOf(attachment.id)
listOfIds = listOf(attachment.id!!)
},{e->
upload_error.visibility = VISIBLE
e.printStackTrace()
......
......@@ -32,7 +32,7 @@ class ProfilePostsRecyclerViewAdapter: RecyclerView.Adapter<ProfilePostsRecycler
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
val post = posts[position]
if (post.sensitive)
if (post.sensitive!!)
setSquareImageFromURL(holder.postView, null, holder.postPreview)
else
setSquareImageFromURL(holder.postView, post.getPostPreviewURL(), holder.postPreview)
......
......@@ -2,9 +2,18 @@ package com.h.pixeldroid.db
import androidx.room.Database
import androidx.room.RoomDatabase
import androidx.room.TypeConverters
@Database(entities = [InstanceDatabaseEntity::class, UserDatabaseEntity::class], version = 1)
@Database(entities = [
InstanceDatabaseEntity::class,
UserDatabaseEntity::class,
PostDatabaseEntity::class
],
version = 1
)
@TypeConverters(Converters::class)
abstract class AppDatabase : RoomDatabase() {
abstract fun instanceDao(): InstanceDao
abstract fun userDao(): UserDao
abstract fun postDao(): PostDao
}
\ No newline at end of file
package com.h.pixeldroid.db
import androidx.room.TypeConverter
import com.google.gson.Gson
class Converters {
@TypeConverter
fun listToJson(list: List<String>): String = Gson().toJson(list)
@TypeConverter
fun jsonToList(json: String): List<String> =
Gson().fromJson(json, Array<String>::class.java).toList()
}
\ No newline at end of file
package com.h.pixeldroid.db
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
@Dao
interface PostDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
fun insertPost(post: PostDatabaseEntity)
@Query("SELECT COUNT(*) FROM posts WHERE user_id=:userId AND instance_uri=:instanceUri")
fun numberOfPosts(userId: String, instanceUri: String): Int
@Query("SELECT * FROM posts WHERE user_id=:user AND instance_uri=:instanceUri ORDER BY date DESC")
fun getAll(user: String, instanceUri: String): List<PostDatabaseEntity>
@Query("SELECT COUNT(*) FROM posts WHERE uri=:uri AND user_id=:userId AND instance_uri=:instanceUri")
fun count(uri: String, userId: String, instanceUri: String): Int
@Query(
"""DELETE FROM posts WHERE uri IN (
SELECT uri FROM posts ORDER BY date ASC LIMIT :nPosts
)"""
)
fun removeOlderPosts(nPosts: Int)
}
\ No newline at end of file
package com.h.pixeldroid.db
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.Index
@Entity(
tableName = "posts",
primaryKeys = ["uri", "user_id", "instance_uri"],
foreignKeys = [ForeignKey(
entity = UserDatabaseEntity::class,
parentColumns = arrayOf("user_id", "instance_uri"),
childColumns = arrayOf("user_id", "instance_uri"),
onUpdate = ForeignKey.CASCADE,
onDelete = ForeignKey.CASCADE
)],
indices = [Index(value = ["user_id"])]
)
data class PostDatabaseEntity (
var uri: String,
var user_id: String,
var instance_uri: String,
var account_profile_picture: String,
var account_name: String,
var media_urls: List<String>,
var favourite_count: Int,
var reply_count: Int,
var share_count: Int,
var description: String,
var date: String,
var likes: Int,
var shares: Int
)
\ No newline at end of file
......@@ -2,6 +2,7 @@ package com.h.pixeldroid.db
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.Index
@Entity(
tableName = "users",
......@@ -12,7 +13,8 @@ import androidx.room.ForeignKey
childColumns = arrayOf("instance_uri"),
onUpdate = ForeignKey.CASCADE,
onDelete = ForeignKey.CASCADE
)]
)],
indices = [Index(value = ["instance_uri"])]
)
data class UserDatabaseEntity (
var user_id: String,
......
......@@ -206,7 +206,7 @@ class CameraFragment : Fragment() {
this, cameraSelector, preview, imageCapture)
// Attach the viewfinder's surface provider to preview use case
preview?.setSurfaceProvider(viewFinder.createSurfaceProvider(camera?.cameraInfo))
preview?.setSurfaceProvider(viewFinder.createSurfaceProvider())
} catch(exc: Exception) {
Log.e(TAG, "Use case binding failed", exc)
}
......
......@@ -45,14 +45,17 @@ class PostFragment : Fragment() {
current_status?.setDescription(root, api, "Bearer $accessToken")
//Activate onclickListeners
current_status?.activateLiker(holder, api, "Bearer $accessToken", current_status.favourited)
current_status?.activateReblogger(holder, api, "Bearer $accessToken", current_status.reblogged)
current_status?.activateLiker(holder, api, "Bearer $accessToken",
current_status.favourited ?: false
)
current_status?.activateReblogger(holder, api, "Bearer $accessToken",
current_status.reblogged ?: false
)
current_status?.activateCommenter(holder, api, "Bearer $accessToken")
current_status?.showComments(holder, api, "Bearer $accessToken")
//Activate double tap liking
current_status?.activateDoubleTapLiker(holder, api, "Bearer $accessToken")
return root
}
......
package com.h.pixeldroid.fragments.feeds
import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.util.Log
import android.view.LayoutInflater
......@@ -19,12 +20,14 @@ import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
import com.h.pixeldroid.MainActivity
import com.h.pixeldroid.R
import com.h.pixeldroid.api.PixelfedAPI
import com.h.pixeldroid.db.AppDatabase
import com.h.pixeldroid.db.UserDatabaseEntity
import com.h.pixeldroid.objects.FeedContent
import com.h.pixeldroid.utils.DBUtils
import com.h.pixeldroid.utils.Utils
import kotlinx.android.synthetic.main.fragment_feed.view.*
import retrofit2.Call
import retrofit2.Callback
......@@ -72,7 +75,13 @@ open class FeedFragment<T: FeedContent, VH: RecyclerView.ViewHolder?>: Fragment(
swipeRefreshLayout.setOnRefreshListener {
//by invalidating data, loadInitial will be called again
factory.liveData.value!!.invalidate()
if (Utils.hasInternet(requireContext())) {
factory.liveData.value!!.invalidate()
} else {
startActivity(Intent(requireContext(), MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
})
}
}
}
......@@ -87,7 +96,7 @@ open class FeedFragment<T: FeedContent, VH: RecyclerView.ViewHolder?>: Fragment(
//We use the id as the key
override fun getKey(item: T): String {
return item.id
return item.id!!
}
//This is called to initialize the list, so we want some of the latest statuses
override fun loadInitial(
......@@ -111,10 +120,12 @@ open class FeedFragment<T: FeedContent, VH: RecyclerView.ViewHolder?>: Fragment(
call.enqueue(object : Callback<List<T>> {
override fun onResponse(call: Call<List<T>>, response: Response<List<T>>) {
if (response.code() == 200) {
val notifications = response.body()!! as ArrayList<T>
callback.onResult(notifications as List<T>)
if (response.isSuccessful && response.body() != null) {
val notifications = response.body()!!
callback.onResult(notifications)
if(this@FeedDataSource.newSource() !is PublicTimelineFragment.SearchFeedDataSource) {
DBUtils.storePosts(db, notifications, user!!)
}
} else{
Toast.makeText(context, getString(R.string.loading_toast), Toast.LENGTH_SHORT).show()
}
......
package com.h.pixeldroid.fragments.feeds
import android.content.Intent
import android.graphics.Color
import android.graphics.Typeface
import android.graphics.drawable.ColorDrawable
import android.graphics.drawable.Drawable
import android.os.Bundle
import androidx.fragment.app.Fragment
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.EditText
import android.widget.ImageView
import android.widget.TextView
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import androidx.viewpager2.adapter.FragmentStateAdapter
import com.bumptech.glide.Glide
import com.bumptech.glide.RequestBuilder
import com.google.android.material.tabs.TabLayoutMediator
import com.h.pixeldroid.MainActivity
import com.h.pixeldroid.R
import kotlinx.android.synthetic.main.fragment_feed.view.feed_fragment_placeholder_text
import com.h.pixeldroid.db.PostDatabaseEntity
import com.h.pixeldroid.fragments.ImageFragment
import com.h.pixeldroid.objects.Status
import com.h.pixeldroid.utils.*
import kotlinx.android.synthetic.main.fragment_offline_feed.view.*
import kotlinx.android.synthetic.main.post_fragment.view.*
/**
* A simple [Fragment] subclass.
* Use the [OfflineFeedFragment.newInstance] factory method to
* create an instance of this fragment.
*/
class OfflineFeedFragment: Fragment() {
private lateinit var recyclerView: RecyclerView
private lateinit var viewAdapter: RecyclerView.Adapter<*>
private lateinit var viewManager: RecyclerView.LayoutManager
lateinit var picRequest: RequestBuilder<Drawable>
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
arguments?.let {
......@@ -29,7 +48,156 @@ class OfflineFeedFragment: Fragment() {
): View? {
// Inflate the layout for this fragment
val view = inflater.inflate(R.layout.fragment_offline_feed, container, false)
view.feed_fragment_placeholder_text.visibility = View.VISIBLE
val loadingAnimation = view.offline_feed_progress_bar
loadingAnimation.visibility = View.VISIBLE
picRequest = Glide.with(this)
.asDrawable().fitCenter()
.placeholder(ColorDrawable(Color.GRAY))
val db = DBUtils.initDB(requireContext())
val user = db.userDao().getActiveUser()!!
if (db.postDao().numberOfPosts(user.user_id, user.instance_uri) > 0) {
val posts = db.postDao().getAll(user.user_id, user.instance_uri)
viewManager = LinearLayoutManager(requireContext())
viewAdapter = OfflinePostFeedAdapter(posts)
loadingAnimation.visibility = View.GONE
recyclerView = view.offline_feed_recyclerview.apply {
visibility = View.VISIBLE
// use this setting to improve performance if you know that changes
// in content do not change the layout size of the RecyclerView
setHasFixedSize(true)
// use a linear layout manager
layoutManager = viewManager
// specify an viewAdapter (see also next example)
adapter = viewAdapter
}
} else {
loadingAnimation.visibility = View.GONE
view.offline_feed_placeholder_text.visibility = View.VISIBLE
}
view.offline_feed_progress_bar.visibility = View.GONE
return view
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
view.swipeRefreshLayout.setOnRefreshListener {
if (Utils.hasInternet(requireContext())) {
onStop()
startActivity(Intent(requireContext(), MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
})
}
view.swipeRefreshLayout.isRefreshing = false
}
}
inner class OfflinePostFeedAdapter(private val posts: List<PostDatabaseEntity>)
: RecyclerView.Adapter<OfflinePostFeedAdapter.OfflinePostViewHolder>() {
inner class OfflinePostViewHolder(private val postView: View)
: RecyclerView.ViewHolder(postView) {
val profilePic : ImageView = postView.findViewById(R.id.profilePic)
val postPic : ImageView = postView.findViewById(R.id.postPicture)
val username : TextView = postView.findViewById(R.id.username)
val description : TextView = postView.findViewById(R.id.description)
val comment : EditText = postView.findViewById(R.id.editComment)
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): OfflinePostViewHolder {
val view = LayoutInflater.from(parent.context)
.inflate(R.layout.post_fragment, parent, false)
.apply {
commenter.visibility = View.GONE
postDomain.visibility = View.GONE
commentIn.visibility = View.GONE
liker.apply {
//de-activate the liker
setEventListener { _, _ ->
false
}
}
reblogger.apply {
//de-activate the reblogger
setEventListener { _, _ ->
false
}
}
}
return OfflinePostViewHolder(view)
}
override fun onBindViewHolder(holder: OfflinePostViewHolder, position: Int) {
val post = posts[position]
val metrics = requireContext().resources.displayMetrics
//Limit the height of the different images
holder.profilePic.maxHeight = metrics.heightPixels
holder.postPic.maxHeight = metrics.heightPixels
//Setup username as a button that opens the profile
holder.itemView.username.apply {
text = post.account_name
setTypeface(null, Typeface.BOLD)
}
//Convert the date to a readable string
Status.ISO8601toDate(post.date, holder.itemView.postDate, false, requireContext())
//Setup images
ImageConverter.setRoundImageFromURL(
holder.itemView,
post.account_profile_picture,
holder.profilePic
)
//Setup post pic only if there are media attachments
if(!post.media_urls.isNullOrEmpty()) {
// Standard layout
holder.postPic.visibility = View.VISIBLE
holder.itemView.postPager.visibility = View.GONE
holder.itemView.postTabs.visibility = View.GONE
holder.itemView.sensitiveWarning.visibility = View.GONE
if(post.media_urls.size == 1) {
picRequest.load(post.media_urls[0]).into(holder.postPic)
} else {
//Only show the viewPager and tabs
holder.postPic.visibility = View.GONE
holder.itemView.postPager.visibility = View.VISIBLE
holder.itemView.postTabs.visibility = View.VISIBLE
val tabs : ArrayList<ImageFragment> = ArrayList()
//Fill the tabs with each mediaAttachment
for(media in post.media_urls) {
tabs.add(ImageFragment.newInstance(media))
}
holder.itemView.postPager.adapter = object : FragmentStateAdapter(this@OfflineFeedFragment) {
override fun createFragment(position: Int): Fragment {
return tabs[position]
}
override fun getItemCount(): Int {
return post.media_urls.size
}
}
TabLayoutMediator(holder.itemView.postTabs, holder.itemView.postPager) { tab, _ ->
tab.icon = holder.itemView.context.getDrawable(R.drawable.ic_dot_blue_12dp)
}.attach()
}
}
holder.description.apply {
if(post.description.isBlank()) {
visibility = View.GONE
} else {
text = HtmlUtils.fromHtml(post.description)
}
}
holder.itemView.nlikes.text = post.likes.toString()
holder.itemView.nshares.text = post.shares.toString()
}
override fun getItemCount(): Int {
return posts.size
}
}
}
......@@ -118,7 +118,7 @@ open class PostsFeedFragment : FeedFragment<Status, PostViewHolder>() {
post.setDescription(holder.postView, api, credential)
//Activate liker
post.activateLiker(holder, api, credential, post.favourited)
post.activateLiker(holder, api, credential, post.favourited ?: false)
//Activate double tap liking
post.activateDoubleTapLiker(holder, api, credential)
......@@ -130,7 +130,7 @@ open class PostsFeedFragment : FeedFragment<Status, PostViewHolder>() {
post.activateCommenter(holder, api, credential)
//Activate Reblogger
post.activateReblogger(holder, api ,credential, post.reblogged)
post.activateReblogger(holder, api ,credential, post.reblogged ?: false)
}
override fun getPreloadItems(position: Int): MutableList<Status> {
......
......@@ -10,7 +10,7 @@ class PublicTimelineFragment: PostsFeedFragment() {
inner class SearchFeedDataSource : FeedDataSource(null, null){
override fun newSource(): FeedDataSource {