hltb example: update snapshot-cart-to-services flow
This commit is contained in:
parent
ec81c2a24f
commit
5b5810c35d
|
@ -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")
|
||||
|
|
|
@ -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>
|
|
@ -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)
|
||||
}
|
|
@ -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()
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
|
||||
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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}")
|
||||
}) {
|
||||
|
|
|
@ -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)"
|
||||
}
|
|
@ -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½ Hours </div>
|
||||
|
|
|
@ -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>
|
|
@ -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>
|
|
@ -0,0 +1,9 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<paths>
|
||||
<cache-path
|
||||
name="cached_files"
|
||||
path="." />
|
||||
<files-path
|
||||
name="images"
|
||||
path="." />
|
||||
</paths>
|
|
@ -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)
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue