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 { dependencies {
// --- defaults // --- defaults
implementation("androidx.core:core-ktx:1.6.0") implementation("androidx.core:core-ktx:1.6.0")
implementation("androidx.activity:activity-ktx:1.3.1")
implementation("androidx.appcompat:appcompat:1.3.1") implementation("androidx.appcompat:appcompat:1.3.1")
implementation("com.google.android.material:material:1.4.0") implementation("com.google.android.material:material:1.4.0")
implementation("androidx.constraintlayout:constraintlayout:2.1.0") implementation("androidx.constraintlayout:constraintlayout:2.1.0")

View File

@ -3,7 +3,7 @@
package="be.kuleuven.howlongtobeat"> package="be.kuleuven.howlongtobeat">
<uses-permission android:name="android.permission.INTERNET" /> <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 <application
android:allowBackup="true" android:allowBackup="true"
@ -21,6 +21,16 @@
<category android:name="android.intent.category.LAUNCHER" /> <category android:name="android.intent.category.LAUNCHER" />
</intent-filter> </intent-filter>
</activity> </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> </application>
</manifest> </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.adapter = adapter
binding.rvGameList.layoutManager = LinearLayoutManager(this.context) 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 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>) { fun onHltbGamesRetrieved(games: List<HowLongToBeatResult>) {
gameList.clear() gameList.clear()

View File

@ -1,11 +1,19 @@
package be.kuleuven.howlongtobeat package be.kuleuven.howlongtobeat
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.ImageView
import android.widget.TextView import android.widget.TextView
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import be.kuleuven.howlongtobeat.hltb.HowLongToBeatResult 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>() { 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) { override fun onBindViewHolder(holder: HltbResultsViewHolder, position: Int) {
val currentResult = items[position] val itm = items[position]
holder.itemView.apply { holder.itemView.apply {
val txtHltbItemResult = findViewById<TextView>(R.id.txtHltbItemResult) findViewById<TextView>(R.id.txtHltbItemResult).text = itm.toString()
txtHltbItemResult.text = currentResult.title 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 package be.kuleuven.howlongtobeat
import android.Manifest import android.Manifest
import android.content.pm.PackageManager
import android.graphics.Bitmap import android.graphics.Bitmap
import android.graphics.BitmapFactory import android.net.Uri
import android.os.Bundle import android.os.Bundle
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts 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.os.bundleOf
import androidx.core.view.isVisible
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.navigation.fragment.findNavController import androidx.navigation.fragment.findNavController
import be.kuleuven.howlongtobeat.cartridges.Cartridge import be.kuleuven.howlongtobeat.cartridges.Cartridge
@ -23,6 +24,8 @@ import be.kuleuven.howlongtobeat.hltb.HowLongToBeatResult
import com.google.android.material.snackbar.Snackbar import com.google.android.material.snackbar.Snackbar
import kotlinx.coroutines.MainScope import kotlinx.coroutines.MainScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.io.File
class LoadingFragment : Fragment(R.layout.fragment_loading) { 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 visionClient: GoogleVisionClient
private lateinit var cameraPermissionActivityResult: ActivityResultLauncher<String> 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 main: MainActivity
private lateinit var binding: FragmentLoadingBinding private lateinit var binding: FragmentLoadingBinding
private var snapshot: Uri? = null
override fun onCreateView( override fun onCreateView(
inflater: LayoutInflater, inflater: LayoutInflater,
container: ViewGroup?, container: ViewGroup?,
@ -47,24 +52,33 @@ class LoadingFragment : Fragment(R.layout.fragment_loading) {
visionClient = GoogleVisionClient() visionClient = GoogleVisionClient()
hltbClient = HLTBClient(main.applicationContext) hltbClient = HLTBClient(main.applicationContext)
cameraActivityResult = registerForActivityResult(ActivityResultContracts.TakePicturePreview(), this::cameraSnapTaken) cameraActivityResult = registerForActivityResult(ActivityResultContracts.TakePicture(), this::cameraSnapTaken)
cameraPermissionActivityResult = registerForActivityResult(ActivityResultContracts.RequestPermission(), this::cameraPermissionAcceptedOrDenied) cameraPermissionActivityResult = registerForActivityResult(ActivityResultContracts.RequestPermission(), this::cameraPermissionAcceptedOrDenied)
binding.btnRetryAfterLoading.setOnClickListener {
tryToMakeCameraSnap()
}
tryToMakeCameraSnap() tryToMakeCameraSnap()
return binding.root 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{ MainScope().launch{
findGameBasedOnCameraSnap(pic) findGameBasedOnCameraSnap(bitmap)
} }
} }
private suspend fun findGameBasedOnCameraSnap(pic: Bitmap) { private suspend fun findGameBasedOnCameraSnap(pic: Bitmap) {
progress("Unleashing Google Vision on the pic...") progress("Unleashing Google Vision on the pic...")
// TODO remove in future val cartCode = visionClient.findCartCodeViaGoogleVision(pic)
val dummypic = BitmapFactory.decodeResource(resources, R.drawable.supermarioland2)
val cartCode = visionClient.findCartCodeViaGoogleVision(dummypic)
if (cartCode == null) { if (cartCode == null) {
errorInProgress("Unable to find a code in your pic. Retry?") errorInProgress("Unable to find a code in your pic. Retry?")
@ -93,35 +107,46 @@ class LoadingFragment : Fragment(R.layout.fragment_loading) {
if(succeeded) { if(succeeded) {
makeCameraSnap() makeCameraSnap()
} else { } else {
progress("Camera permission required!") errorInProgress("Camera permission required!")
} }
} }
private fun tryToMakeCameraSnap() { private fun tryToMakeCameraSnap() {
binding.btnRetryAfterLoading.hide()
progress("Making snapshot with camera...") 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) 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() { private fun makeCameraSnap() {
cameraActivityResult.launch(null) createNewTempCameraFile()
cameraActivityResult.launch(snapshot)
} }
private fun progress(msg: String) { private fun progress(msg: String) {
if(!binding.indeterminateBar.isAnimating) { if(!binding.indeterminateBar.isVisible) {
binding.indeterminateBar.animate() binding.indeterminateBar.visibility = View.VISIBLE
} }
binding.txtLoading.text = msg binding.txtLoading.text = msg
} }
private fun errorInProgress(msg: String) { private fun errorInProgress(msg: String) {
progress(msg) progress(msg)
binding.indeterminateBar.clearAnimation() binding.indeterminateBar.visibility = View.GONE
Snackbar.make(requireView(), msg, Snackbar.LENGTH_LONG).show() 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 // 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) private val vision = Vision.Builder(NetHttpTransport(), GsonFactory.getDefaultInstance(), null)
.setVisionRequestInitializer(VisionRequestInitializer("AIzaSyCaMjQQOY7508y95riDhr25fsrqe3m2JW0")) .setVisionRequestInitializer(VisionRequestInitializer("AIzaSyCaMjQQOY7508y95riDhr25fsrqe3m2JW0"))
.setApplicationName("How Long To Beat")
.build() .build()
suspend fun findCartCodeViaGoogleVision(cameraSnap: Bitmap): String? { suspend fun findCartCodeViaGoogleVision(cameraSnap: Bitmap): String? {
@ -39,7 +40,9 @@ class GoogleVisionClient {
} }
response = vision.images().annotate(batch).execute() 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 return null
} }
@ -48,4 +51,4 @@ class GoogleVisionClient {
}.firstOrNull() }.firstOrNull()
return gbId?.description ?: null return gbId?.description ?: null
} }
} }

View File

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

View File

@ -1,11 +1,16 @@
package be.kuleuven.howlongtobeat.hltb package be.kuleuven.howlongtobeat.hltb
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import java.net.URL
@Serializable @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 { companion object {
const val RESULT = "HowLongToBeatResult" const val RESULT = "HowLongToBeatResult"
const val CODE = "CartCode" 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 titleMatcher = """<a class=".+" title="(.+)" href=""".toRegex()
private val hourMatcher = """<div class=".+">(.+) Hours""".toRegex() private val hourMatcher = """<div class=".+">(.+) Hours""".toRegex()
private val boxArtMatcher = """<img alt=".+" src="(.+)"""".toRegex()
fun parse(html: String): List<HowLongToBeatResult> { fun parse(html: String): List<HowLongToBeatResult> {
val result = arrayListOf<HowLongToBeatResult>() val result = arrayListOf<HowLongToBeatResult>()
@ -13,14 +14,26 @@ object HowLongToBeatResultParser {
if(matched != null) { if(matched != null) {
val (title) = matched.destructured val (title) = matched.destructured
val hour = parseHoursFromRow(i, rows) val hour = parseHoursFromRow(i, rows)
val boxart = parseBoxArtFromRow(i, rows)
result.add(HowLongToBeatResult(title, hour)) result.add(HowLongToBeatResult(title, hour, boxart))
} }
} }
return result 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 { private fun parseHoursFromRow(row: Int, rows: List<String>): Double {
var hour = -1.0 var hour = -1.0
// two rows down, there should be a <div class="search_list_tidbit center time_100">6&#189; Hours </div> // 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_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent" app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent" app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" /> app:layout_constraintTop_toTopOf="parent"
android:indeterminate="true" />
<TextView <TextView
android:id="@+id/txtLoading" android:id="@+id/txtLoading"
@ -25,4 +26,17 @@
app:layout_constraintStart_toStartOf="parent" app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="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> </androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -1,5 +1,6 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android" <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_width="match_parent"
android:layout_height="100dp" android:layout_height="100dp"
xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:app="http://schemas.android.com/apk/res-auto"
@ -12,9 +13,19 @@
android:text="Some Result" android:text="Some Result"
android:textSize="24sp" android:textSize="24sp"
app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="parent" app:layout_constraintEnd_toStartOf="@+id/imgHltbItemResult"
app:layout_constraintStart_toStartOf="parent" app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="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> </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(3, result.size)
assertEquals("Super Mario Land", smland.title) assertEquals("Super Mario Land", smland.title)
assertEquals("Super Mario 3D Land", sm3dland.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(1.0, smland.howlong, 0.0)
assertEquals(6.5, sm3dland.howlong, 0.0) assertEquals(6.5, sm3dland.howlong, 0.0)
} }