hltb example: update snapshot-cart-to-services flow

This commit is contained in:
Wouter Groeneveld 2021-08-17 15:58:19 +02:00
parent ec81c2a24f
commit 5b5810c35d
14 changed files with 171 additions and 35 deletions

View File

@ -43,6 +43,7 @@ android {
dependencies {
// --- defaults
implementation("androidx.core:core-ktx:1.6.0")
implementation("androidx.activity:activity-ktx:1.3.1")
implementation("androidx.appcompat:appcompat:1.3.1")
implementation("com.google.android.material:material:1.4.0")
implementation("androidx.constraintlayout:constraintlayout:2.1.0")

View File

@ -3,7 +3,7 @@
package="be.kuleuven.howlongtobeat">
<uses-permission android:name="android.permission.INTERNET" />
<uses-feature android:name="android.hardware.camera" android:required="false" />
<uses-permission android:name="android.permission.CAMERA" android:required="false" />
<application
android:allowBackup="true"
@ -21,6 +21,16 @@
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<!-- WHY? See https://medium.com/codex/how-to-use-the-android-activity-result-api-for-selecting-and-taking-images-5dbcc3e6324b -->
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.provider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/provider_paths" />
</provider>
</application>
</manifest>

View File

@ -0,0 +1,18 @@
package be.kuleuven.howlongtobeat
import android.app.Activity
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.net.Uri
import kotlin.math.roundToInt
fun Uri.toBitmap(activity: Activity): Bitmap {
return BitmapFactory.decodeStream(activity.contentResolver.openInputStream(this))
}
fun Bitmap.scaleToWidth(width: Int): Bitmap {
val aspectRatio = this.width.toFloat() / this.height.toFloat()
val height = (width / aspectRatio).roundToInt()
return Bitmap.createScaledBitmap(this, width, height, false)
}

View File

@ -35,7 +35,9 @@ class GameListFragment : Fragment(R.layout.fragment_gamelist) {
binding.rvGameList.adapter = adapter
binding.rvGameList.layoutManager = LinearLayoutManager(this.context)
binding.btnAddTodo.setOnClickListener(this::addNewTotoItem)
binding.btnAddTodo.setOnClickListener {
findNavController().navigate(R.id.action_gameListFragment_to_loadingFragment)
}
return binding.root
}
@ -46,10 +48,6 @@ class GameListFragment : Fragment(R.layout.fragment_gamelist) {
}
}
private fun addNewTotoItem(it: View) {
findNavController().navigate(R.id.action_gameListFragment_to_loadingFragment)
}
/*
fun onHltbGamesRetrieved(games: List<HowLongToBeatResult>) {
gameList.clear()

View File

@ -1,11 +1,19 @@
package be.kuleuven.howlongtobeat
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.ImageView
import android.widget.TextView
import androidx.recyclerview.widget.RecyclerView
import be.kuleuven.howlongtobeat.hltb.HowLongToBeatResult
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.MainScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
class HltbResultsAdapter(private val items: List<HowLongToBeatResult>) : RecyclerView.Adapter<HltbResultsAdapter.HltbResultsViewHolder>() {
@ -17,10 +25,25 @@ class HltbResultsAdapter(private val items: List<HowLongToBeatResult>) : Recycle
}
override fun onBindViewHolder(holder: HltbResultsViewHolder, position: Int) {
val currentResult = items[position]
val itm = items[position]
holder.itemView.apply {
val txtHltbItemResult = findViewById<TextView>(R.id.txtHltbItemResult)
txtHltbItemResult.text = currentResult.title
findViewById<TextView>(R.id.txtHltbItemResult).text = itm.toString()
val boxArtView = findViewById<ImageView>(R.id.imgHltbItemResult)
if(itm.hasBoxart()) {
MainScope().launch{
var art: Bitmap? = null
withContext(Dispatchers.IO) {
art = BitmapFactory.decodeStream(itm.boxartUrl().openConnection().getInputStream())
}
withContext(Dispatchers.Main) {
boxArtView.setImageBitmap(art)
}
}
} else {
boxArtView.visibility = View.INVISIBLE
}
}
}

View File

@ -1,17 +1,18 @@
package be.kuleuven.howlongtobeat
import android.Manifest
import android.content.pm.PackageManager
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.net.Uri
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts
import androidx.core.content.ContextCompat
import androidx.core.content.FileProvider
import androidx.core.content.PermissionChecker
import androidx.core.os.bundleOf
import androidx.core.view.isVisible
import androidx.fragment.app.Fragment
import androidx.navigation.fragment.findNavController
import be.kuleuven.howlongtobeat.cartridges.Cartridge
@ -23,6 +24,8 @@ import be.kuleuven.howlongtobeat.hltb.HowLongToBeatResult
import com.google.android.material.snackbar.Snackbar
import kotlinx.coroutines.MainScope
import kotlinx.coroutines.launch
import java.io.File
class LoadingFragment : Fragment(R.layout.fragment_loading) {
@ -31,10 +34,12 @@ class LoadingFragment : Fragment(R.layout.fragment_loading) {
private lateinit var visionClient: GoogleVisionClient
private lateinit var cameraPermissionActivityResult: ActivityResultLauncher<String>
private lateinit var cameraActivityResult: ActivityResultLauncher<Void>
private lateinit var cameraActivityResult: ActivityResultLauncher<Uri>
private lateinit var main: MainActivity
private lateinit var binding: FragmentLoadingBinding
private var snapshot: Uri? = null
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
@ -47,24 +52,33 @@ class LoadingFragment : Fragment(R.layout.fragment_loading) {
visionClient = GoogleVisionClient()
hltbClient = HLTBClient(main.applicationContext)
cameraActivityResult = registerForActivityResult(ActivityResultContracts.TakePicturePreview(), this::cameraSnapTaken)
cameraActivityResult = registerForActivityResult(ActivityResultContracts.TakePicture(), this::cameraSnapTaken)
cameraPermissionActivityResult = registerForActivityResult(ActivityResultContracts.RequestPermission(), this::cameraPermissionAcceptedOrDenied)
binding.btnRetryAfterLoading.setOnClickListener {
tryToMakeCameraSnap()
}
tryToMakeCameraSnap()
return binding.root
}
private fun cameraSnapTaken(pic: Bitmap) {
private fun cameraSnapTaken(succeeded: Boolean) {
if(!succeeded || snapshot == null) {
errorInProgress("Photo could not be saved, try again?")
return
}
progress("Scaling image for upload...")
val bitmap = snapshot!!.toBitmap(main).scaleToWidth(1600)
MainScope().launch{
findGameBasedOnCameraSnap(pic)
findGameBasedOnCameraSnap(bitmap)
}
}
private suspend fun findGameBasedOnCameraSnap(pic: Bitmap) {
progress("Unleashing Google Vision on the pic...")
// TODO remove in future
val dummypic = BitmapFactory.decodeResource(resources, R.drawable.supermarioland2)
val cartCode = visionClient.findCartCodeViaGoogleVision(dummypic)
val cartCode = visionClient.findCartCodeViaGoogleVision(pic)
if (cartCode == null) {
errorInProgress("Unable to find a code in your pic. Retry?")
@ -93,35 +107,46 @@ class LoadingFragment : Fragment(R.layout.fragment_loading) {
if(succeeded) {
makeCameraSnap()
} else {
progress("Camera permission required!")
errorInProgress("Camera permission required!")
}
}
private fun tryToMakeCameraSnap() {
binding.btnRetryAfterLoading.hide()
progress("Making snapshot with camera...")
if(ContextCompat.checkSelfPermission(main, Manifest.permission.CAMERA) != PackageManager.PERMISSION_GRANTED) {
if(PermissionChecker.checkSelfPermission(main, Manifest.permission.CAMERA) != PermissionChecker.PERMISSION_GRANTED) {
cameraPermissionActivityResult.launch(Manifest.permission.CAMERA)
} else {
makeCameraSnap()
}
makeCameraSnap()
}
private fun createNewTempCameraFile() {
val tempFile = File.createTempFile("hltbCameraSnap", ".png", main.cacheDir).apply {
createNewFile()
deleteOnExit()
}
snapshot = FileProvider.getUriForFile(main.applicationContext, "${BuildConfig.APPLICATION_ID}.provider", tempFile)
}
private fun makeCameraSnap() {
cameraActivityResult.launch(null)
createNewTempCameraFile()
cameraActivityResult.launch(snapshot)
}
private fun progress(msg: String) {
if(!binding.indeterminateBar.isAnimating) {
binding.indeterminateBar.animate()
if(!binding.indeterminateBar.isVisible) {
binding.indeterminateBar.visibility = View.VISIBLE
}
binding.txtLoading.text = msg
}
private fun errorInProgress(msg: String) {
progress(msg)
binding.indeterminateBar.clearAnimation()
binding.indeterminateBar.visibility = View.GONE
Snackbar.make(requireView(), msg, Snackbar.LENGTH_LONG).show()
// todo show retry button or something?
binding.btnRetryAfterLoading.show()
}
}

View File

@ -19,6 +19,7 @@ class GoogleVisionClient {
// TODO encrypt and store externally: https://cloud.google.com/docs/authentication/api-keys?hl=en&visit_id=637642790375688006-1838986332&rd=1
private val vision = Vision.Builder(NetHttpTransport(), GsonFactory.getDefaultInstance(), null)
.setVisionRequestInitializer(VisionRequestInitializer("AIzaSyCaMjQQOY7508y95riDhr25fsrqe3m2JW0"))
.setApplicationName("How Long To Beat")
.build()
suspend fun findCartCodeViaGoogleVision(cameraSnap: Bitmap): String? {
@ -39,7 +40,9 @@ class GoogleVisionClient {
}
response = vision.images().annotate(batch).execute()
}
if(response.responses.isEmpty()) {
if(response.responses.isEmpty()
|| response.responses.get(0).textAnnotations == null
|| response.responses.get(0).textAnnotations.isEmpty()) {
return null
}
@ -48,4 +51,4 @@ class GoogleVisionClient {
}.firstOrNull()
return gbId?.description ?: null
}
}
}

View File

@ -7,10 +7,14 @@ import com.android.volley.toolbox.Volley
class HLTBClient(val context: Context) {
companion object {
const val DOMAIN = "https://howlongtobeat.com"
}
// Inspired by https://www.npmjs.com/package/howlongtobeat
// The API is abysmal, but hey, it works...
class HLTBRequest(val query: String, responseListener: Response.Listener<String>) :
StringRequest(Method.POST, "https://howlongtobeat.com/search_results.php?page=1", responseListener,
StringRequest(Method.POST, "$DOMAIN/search_results.php?page=1", responseListener,
Response.ErrorListener {
println("Something went wrong: ${it.message}")
}) {

View File

@ -1,11 +1,16 @@
package be.kuleuven.howlongtobeat.hltb
import kotlinx.serialization.Serializable
import java.net.URL
@Serializable
data class HowLongToBeatResult(val title: String, val howlong: Double, val finished: Boolean = false) : java.io.Serializable {
data class HowLongToBeatResult(val title: String, val howlong: Double, val boxart: String = "") : java.io.Serializable {
companion object {
const val RESULT = "HowLongToBeatResult"
const val CODE = "CartCode"
}
fun hasBoxart(): Boolean = boxart.startsWith(HLTBClient.DOMAIN)
fun boxartUrl(): URL = URL(boxart)
override fun toString(): String = "$title ($howlong hrs)"
}

View File

@ -4,6 +4,7 @@ object HowLongToBeatResultParser {
private val titleMatcher = """<a class=".+" title="(.+)" href=""".toRegex()
private val hourMatcher = """<div class=".+">(.+) Hours""".toRegex()
private val boxArtMatcher = """<img alt=".+" src="(.+)"""".toRegex()
fun parse(html: String): List<HowLongToBeatResult> {
val result = arrayListOf<HowLongToBeatResult>()
@ -13,14 +14,26 @@ object HowLongToBeatResultParser {
if(matched != null) {
val (title) = matched.destructured
val hour = parseHoursFromRow(i, rows)
val boxart = parseBoxArtFromRow(i, rows)
result.add(HowLongToBeatResult(title, hour))
result.add(HowLongToBeatResult(title, hour, boxart))
}
}
return result
}
private fun parseBoxArtFromRow(row: Int, rows: List<String>): String {
// three rows up, there should be an image tag with the box art
if(row - 3 >= 0) {
val matchedBoxArt = boxArtMatcher.find(rows[row - 3])
if(matchedBoxArt != null) {
return HLTBClient.DOMAIN + matchedBoxArt.groupValues[1]
}
}
return ""
}
private fun parseHoursFromRow(row: Int, rows: List<String>): Double {
var hour = -1.0
// two rows down, there should be a <div class="search_list_tidbit center time_100">6&#189; Hours </div>

View File

@ -12,7 +12,8 @@
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
app:layout_constraintTop_toTopOf="parent"
android:indeterminate="true" />
<TextView
android:id="@+id/txtLoading"
@ -25,4 +26,17 @@
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<com.google.android.material.floatingactionbutton.FloatingActionButton
android:id="@+id/btnRetryAfterLoading"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="16dp"
android:layout_marginBottom="16dp"
android:contentDescription="Retry"
android:src="@android:drawable/ic_menu_rotate"
android:visibility="invisible"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -1,5 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="100dp"
xmlns:app="http://schemas.android.com/apk/res-auto"
@ -12,9 +13,19 @@
android:text="Some Result"
android:textSize="24sp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="parent"
app:layout_constraintEnd_toStartOf="@+id/imgHltbItemResult"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<ImageView
android:id="@+id/imgHltbItemResult"
android:layout_width="105dp"
android:layout_height="72dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_bias="0.727"
app:srcCompat="@android:drawable/ic_menu_gallery" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<paths>
<cache-path
name="cached_files"
path="." />
<files-path
name="images"
path="." />
</paths>

View File

@ -73,6 +73,8 @@ I/System.out: <div class="search_list_tidbit text_white shadow_text">Ma
assertEquals(3, result.size)
assertEquals("Super Mario Land", smland.title)
assertEquals("Super Mario 3D Land", sm3dland.title)
assertEquals("https://howlongtobeat.com/games/250px-Supermariolandboxart.jpg", smland.boxart)
assertEquals("https://howlongtobeat.com/games/250px-Super-Mario-3D-Land-Logo.jpg", sm3dland.boxart)
assertEquals(1.0, smland.howlong, 0.0)
assertEquals(6.5, sm3dland.howlong, 0.0)
}