Commit 81ae3e64 authored by Jonas L.'s avatar Jonas L.

Add preview images to the project list

This closes #1
parent 4bb44bdb
{
"formatVersion": 1,
"database": {
"version": 2,
"identityHash": "69d8c2f8c236aa015d1302a69fa846b1",
"entities": [
{
"tableName": "downloaded_local_network_project",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`project_id` TEXT NOT NULL, `hash` TEXT NOT NULL, `local_filename` TEXT NOT NULL, `file_size` INTEGER NOT NULL, `title` TEXT NOT NULL, `description` TEXT NOT NULL, PRIMARY KEY(`project_id`))",
"fields": [
{
"fieldPath": "projectId",
"columnName": "project_id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "hash",
"columnName": "hash",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "localFilename",
"columnName": "local_filename",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "fileSize",
"columnName": "file_size",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "title",
"columnName": "title",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "description",
"columnName": "description",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"columnNames": [
"project_id"
],
"autoGenerate": false
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "package_source_project",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`project_id` TEXT NOT NULL, `url` TEXT NOT NULL, `image` TEXT NOT NULL, `image_source` TEXT NOT NULL, `local_image_filename` TEXT NOT NULL, `package_source_url` TEXT, `local_hash` TEXT, `local_filename` TEXT, `local_file_size` INTEGER, `title` TEXT NOT NULL, `resolution` INTEGER NOT NULL, PRIMARY KEY(`project_id`))",
"fields": [
{
"fieldPath": "projectId",
"columnName": "project_id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "url",
"columnName": "url",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "image",
"columnName": "image",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "imageSource",
"columnName": "image_source",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "localImageFilename",
"columnName": "local_image_filename",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "packageSourceUrl",
"columnName": "package_source_url",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "localHash",
"columnName": "local_hash",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "localFilename",
"columnName": "local_filename",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "projectJsonLocalFileSize",
"columnName": "local_file_size",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "title",
"columnName": "title",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "resolution",
"columnName": "resolution",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"columnNames": [
"project_id"
],
"autoGenerate": false
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "package_source",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`url` TEXT NOT NULL, `last_query_failed` INTEGER NOT NULL, PRIMARY KEY(`url`))",
"fields": [
{
"fieldPath": "url",
"columnName": "url",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "lastQueryFailed",
"columnName": "last_query_failed",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"columnNames": [
"url"
],
"autoGenerate": false
},
"indices": [],
"foreignKeys": []
}
],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, \"69d8c2f8c236aa015d1302a69fa846b1\")"
]
}
}
\ No newline at end of file
......@@ -16,6 +16,7 @@ class ContentStorage private constructor(context: Context) {
val contentFilesDirectory = File(ContextCompat.getDataDir(context), "determapp_content")
val imageFilesBaseDirectory = File(context.getExternalFilesDir(null), "determapp_image")
val previewImagesDirectory = File(ContextCompat.getDataDir(context), "determapp_preview_images")
// new items should be added before they are written
// items should only be removed when they are deleted manually and not referenced anywhere
......@@ -24,6 +25,7 @@ class ContentStorage private constructor(context: Context) {
init {
contentFilesDirectory.mkdirs()
imageFilesBaseDirectory.mkdirs()
previewImagesDirectory.mkdirs()
Async.disk.submit { this.cleanUpContentFilesStorage() }
}
......
......@@ -15,7 +15,7 @@ import de.determapp.android.content.database.item.PackageSourceProject
PackageSourceProject::class,
PackageSource::class
],
version = 1
version = 2
)
abstract class AppDatabase : RoomDatabase() {
abstract fun localNetworkProjectDao(): DownloadedLocalNetworkProjectDao
......
......@@ -2,6 +2,8 @@ package de.determapp.android.content.database
import android.content.Context
import androidx.room.Room
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
object AppDatabaseInstance {
private var instance: AppDatabase? = null
......@@ -13,7 +15,15 @@ object AppDatabaseInstance {
context.applicationContext,
AppDatabase::class.java,
"db"
).build()
)
.addMigrations(object: Migration(1, 2) {
override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL("ALTER TABLE package_source_project ADD COLUMN `image` TEXT NOT NULL DEFAULT \"\"")
database.execSQL("ALTER TABLE package_source_project ADD COLUMN `image_source` TEXT NOT NULL DEFAULT \"\"")
database.execSQL("ALTER TABLE package_source_project ADD COLUMN `local_image_filename` TEXT NOT NULL DEFAULT \"\"")
}
})
.build()
}
return instance!!
......
......@@ -23,14 +23,20 @@ interface PackageSourceProjectDao {
@Query("SELECT * from package_source_project WHERE package_source_url = :packageSourceUrl")
fun getByPackageSourceSync(packageSourceUrl: String): List<PackageSourceProject>
@Query("SELECT * FROM package_source_project WHERE package_source_url != \"\" AND image != \"\" AND local_image_filename = \"\"")
fun getWherePreviewImageMissingAndAvailable(): List<PackageSourceProject>
@Query("SELECT * FROM package_source_project WHERE local_image_filename != \"\"")
fun getWherePreviewImageDownloaded(): List<PackageSourceProject>
@Insert
fun insert(item: PackageSourceProject)
@Query("UPDATE package_source_project SET package_source_url = :packageSourceUrl WHERE project_id = :projectId")
fun updatePackageSource(projectId: String, packageSourceUrl: String?)
@Query("UPDATE package_source_project SET url = :url, title = :title WHERE project_id = :projectId")
fun updateUrlAndTitle(projectId: String, url: String, title: String)
@Query("UPDATE package_source_project SET url = :url, title = :title, image = :image, image_source = :imageSource WHERE project_id = :projectId")
fun updateBaseData(projectId: String, url: String, title: String, image: String, imageSource: String)
@Query("UPDATE package_source_project SET resolution = :selectedResolution WHERE project_id = :projectId")
fun updateSelectedResolution(projectId: String, selectedResolution: Int)
......@@ -38,6 +44,9 @@ interface PackageSourceProjectDao {
@Query("UPDATE package_source_project SET local_filename = :localFilename, local_hash = :localHash, local_file_size = :fileSize WHERE project_id = :projectId")
fun updateLocalFile(projectId: String, localFilename: String?, localHash: String?, fileSize: Long?)
@Query("UPDATE package_source_project SET local_image_filename = :localImageFilename WHERE project_id = :projectId")
fun updateLocalImageFilename(projectId: String, localImageFilename: String)
@Query("DELETE from package_source_project WHERE project_id = :projectId")
fun removeById(projectId: String)
}
......@@ -10,6 +10,11 @@ data class PackageSourceProject(
@ColumnInfo(name = "project_id")
val projectId: String,
val url: String,
val image: String,
@ColumnInfo(name = "image_source")
val imageSource: String,
@ColumnInfo(name = "local_image_filename")
val localImageFilename: String,
// this can be empty (if there is no longer a package source which contains it)
// packages should only be kept if they are still at a package source or downloaded
// the value should be the package source from which the package was received and processed the first time
......
......@@ -67,6 +67,8 @@ private fun readPackageSourceEntry(reader: JsonReader): PackageSourceEntry {
var projectId: String? = null
var projectUrl: String? = null
var title: String? = null
var image = ""
var imageSource = ""
reader.beginObject()
while (reader.hasNext()) {
......@@ -74,17 +76,29 @@ private fun readPackageSourceEntry(reader: JsonReader): PackageSourceEntry {
"projectId" -> projectId = reader.nextString()
"projectUrl" -> projectUrl = reader.nextString()
"title" -> title = reader.nextString()
"image" -> image = reader.nextString()
"imageSource" -> imageSource = reader.nextString()
else -> reader.skipValue()
}
}
reader.endObject()
return PackageSourceEntry(
projectId!!, projectUrl!!, title!!
projectId = projectId!!,
projectUrl = projectUrl!!,
title = title!!,
image = image,
imageSource = imageSource
)
}
data class PackageSourceEntry(val projectId: String, val projectUrl: String, val title: String) {
data class PackageSourceEntry(
val projectId: String,
val projectUrl: String,
val title: String,
val image: String = "",
val imageSource: String = ""
) {
init {
Validator.assertIdValid(projectId)
......
package de.determapp.android.content.packagesource
import android.content.Context
import de.determapp.android.Http
import de.determapp.android.content.ContentStorage
import de.determapp.android.content.database.AppDatabaseInstance
import de.determapp.android.content.database.item.PackageSource
import de.determapp.android.content.database.item.PackageSourceProject
import okhttp3.HttpUrl
import okhttp3.Request
import okio.Okio
import java.io.File
import java.io.IOException
import java.util.*
private val updatePackageSourceProjectsLock = Object()
......@@ -36,6 +44,15 @@ fun addPackageSource(packageSourceUrl: String, context: Context) {
// add content to the database
processPackageSourceContent(realUrl, content, context)
// download the images
try {
// TODO: only do this for the new package source
updatePackageSourceImages(context)
} catch (ex: Exception) {
// ignore if this fails
}
}
}
......@@ -58,6 +75,8 @@ fun processPackageSourceContent(packageSourceUrl: String, content: List<PackageS
PackageSourceProject(
projectId = item.projectId,
url = item.projectUrl,
image = item.image,
imageSource = item.imageSource,
packageSourceUrl = packageSourceUrl,
localHash = null,
localFilename = null,
......@@ -65,7 +84,8 @@ fun processPackageSourceContent(packageSourceUrl: String, content: List<PackageS
// hardcoded default value
// good enough for the eye
// small enough for the data contingent
resolution = 1024
resolution = 1024,
localImageFilename = ""
)
)
} else {
......@@ -80,9 +100,22 @@ fun processPackageSourceContent(packageSourceUrl: String, content: List<PackageS
if (shouldEventuallyUpdate) {
// eventually update
if (
oldEntry.title != item.title || oldEntry.url != item.projectUrl ||
oldEntry.image != item.image || oldEntry.imageSource != item.imageSource
) {
db.updateBaseData(
projectId = item.projectId,
url = item.projectUrl,
title = item.title,
image = item.image,
imageSource = item.imageSource
)
if (oldEntry.title != item.title || oldEntry.url != item.projectUrl) {
db.updateUrlAndTitle(item.projectId, item.projectUrl, item.title)
if (oldEntry.image != item.image) {
// remove it to make the image download query the new one
db.updateLocalImageFilename(item.projectId, "")
}
}
}
}
......@@ -115,3 +148,50 @@ fun removePackageSource(packageSourceUrl: String, context: Context) {
db.packageSourceDao().remove(packageSourceUrl)
}
}
fun updatePackageSourceImages(context: Context) {
val db = AppDatabaseInstance.with(context)
val storage = ContentStorage.with(context)
synchronized(updatePackageSourceProjectsLock) {
db.packageSourceProjectDao().getWherePreviewImageMissingAndAvailable().forEach { project ->
try {
Http.uncachedClient.newCall(
Request.Builder()
.url(
HttpUrl.parse(project.packageSourceUrl!!)!!
.resolve("./image/package/${project.image}")!!
).build()
).execute().use { response ->
if (!response.isSuccessful) {
throw IOException("request failed")
}
val filename = UUID.randomUUID().toString()
val file = File(storage.previewImagesDirectory, filename)
// save locally
Okio.buffer(Okio.sink(file)).use { sink ->
sink.writeAll(response.body()!!.source())
}
// update database
db.packageSourceProjectDao().updateLocalImageFilename(
projectId = project.projectId,
localImageFilename = filename
)
}
} catch (ex: Exception) {
// TODO: logging of errors
}
}
val expectedDownloadedPreviewImages = db.packageSourceProjectDao().getWherePreviewImageDownloaded().map { it.localImageFilename }.toSet()
val savedPreviewImages = storage.previewImagesDirectory.listFiles()
// delete images which are not used anymore
savedPreviewImages
.filterNot { expectedDownloadedPreviewImages.contains(it.name) }
.forEach { it.delete() }
}
}
......@@ -46,6 +46,14 @@ fun updatePackageSourcesSync(context: Context): UpdatePackageSourcesResult {
}
}
try {
updatePackageSourceImages(context)
} catch (ex: Throwable) {
if (BuildConfig.DEBUG) {
Log.d(LOG_TAG, "error during updating package source images", ex)
}
}
return UpdatePackageSourcesResult(
Collections.unmodifiableList(failedUrls),
Collections.unmodifiableList(processedUrls)
......
......@@ -37,7 +37,8 @@ class ContentList private constructor(context: Context) {
result.add(ContentListItem(
item.title,
item.projectId,
PackageSource.DownloadedFromLocalNetwork
PackageSource.DownloadedFromLocalNetwork,
localImageFilename = ""
))
}
}
......@@ -47,7 +48,8 @@ class ContentList private constructor(context: Context) {
result.add(ContentListItem(
item.title,
item.projectId,
PackageSource.WebPackageSource
PackageSource.WebPackageSource,
localImageFilename = item.localImageFilename
))
}
}
......
package de.determapp.android.ui.contentlist
import androidx.recyclerview.widget.RecyclerView
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import com.squareup.picasso.Picasso
import de.determapp.android.R
import de.determapp.android.content.ContentStorage
import de.determapp.android.databinding.ContentListItemBinding
import java.io.File
class ContentListAdapter: RecyclerView.Adapter<ViewHolder>() {
private var handlers: Handlers? = null
......@@ -35,9 +39,7 @@ class ContentListAdapter: RecyclerView.Adapter<ViewHolder>() {
}
}
override fun getItemId(position: Int): Long {
return getItem(position).generateId()
}
override fun getItemId(position: Int) = getItem(position).generateId()
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
return ViewHolder(
......@@ -50,8 +52,31 @@ class ContentListAdapter: RecyclerView.Adapter<ViewHolder>() {
}
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
holder.binding.item = getItem(position)
holder.binding.handlers = this.handlers
val item = getItem(position)
holder.binding.item = item
holder.binding.handlers = handlers
if (item.localImageFilename.isEmpty()) {
Picasso.get()
.load(null as String?)
.placeholder(R.mipmap.ic_launcher)
.fit()
.noFade()
.into(holder.binding.previewImage)
} else {
Picasso.get()
.load(
File(
ContentStorage.with(holder.binding.root.context).previewImagesDirectory,
item.localImageFilename
)
)
.placeholder(R.mipmap.ic_launcher)
.fit()
.noFade()
.into(holder.binding.previewImage)
}
}
}
......
......@@ -14,6 +14,7 @@ import de.determapp.android.ui.viewer.ProjectSpec
import de.determapp.android.ui.viewer.ViewerActivity
import kotlinx.android.synthetic.main.fragment_content_list.*
// TODO: show image sources
class ContentListFragment: Fragment(), Handlers {
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
return inflater.inflate(R.layout.fragment_content_list, container, false)
......
......@@ -5,7 +5,8 @@ import de.determapp.android.ui.viewer.PackageSource
data class ContentListItem(
val title: String,
val projectId: String,
val source: PackageSource
val source: PackageSource,
val localImageFilename: String
) {
fun generateId(): Long {
return 5000000000L * source.ordinal.toLong() + projectId.hashCode().toLong()
......@@ -14,4 +15,4 @@ data class ContentListItem(
interface Handlers {
fun onContentListItemClicked(item: ContentListItem)
}
\ No newline at end of file
}
......@@ -28,7 +28,7 @@ class ContentListModel(application: Application): AndroidViewModel(application)
didMadeAnyRequests = true
running.value = true
Async.network.submit({
Async.network.submit {
val result = updatePackageSourcesSync(getApplication())
runOnUiThread(Runnable {
......@@ -36,6 +36,6 @@ class ContentListModel(application: Application): AndroidViewModel(application)
response.value = result
running.value = false
})
})
}
}
}
......@@ -13,9 +13,7 @@
</data>
<FrameLayout
android:paddingTop="@dimen/view_margin"
android:paddingLeft="@dimen/view_margin"
android:paddingRight="@dimen/view_margin"
android:padding="4dp"
android:layout_width="match_parent"
android:layout_height="wrap_content">
......@@ -26,21 +24,31 @@
android:layout_width="match_parent"
android:layout_height="wrap_content">
<LinearLayout
android:padding="@dimen/view_margin"
<RelativeLayout
android:orientation="vertical"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<TextView
android:textColor="@color/colorPrimaryReallyDark"
android:textStyle="bold"
android:textAppearance="?android:textAppearanceLarge"
tools:text="Wirbellose im Wasser"
android:text="@{item.title}"
android:layout_width="match_parent"
android:layout_height="match_parent" />
<de.determapp.android.ui.view.AspectRatioImageView
android:id="@+id/preview_image"
android:src="@mipmap/ic_launcher"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
<TextView
android:background="#88000000"
android:paddingEnd="16dp"
android:paddingStart="16dp"
android:paddingBottom="8dp"
android:layout_alignParentBottom="true"
android:textColor="@color/md_white_1000"
android:textAppearance="?android:textAppearanceLarge"
tools:text="Wirbellose im Wasser"
android:text="@{item.title}"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
</LinearLayout>
</RelativeLayout>
</androidx.cardview.widget.CardView>
</FrameLayout>
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment