Initial commit

This commit is contained in:
Josh Sharp 2020-06-21 15:18:26 +10:00
commit 618420f22a
43 changed files with 1084 additions and 0 deletions

1
.gitignore vendored Normal file
View file

@ -0,0 +1 @@
/build

52
build.gradle Normal file
View file

@ -0,0 +1,52 @@
apply plugin: 'com.android.application'
apply plugin: 'kotlin-android'
apply plugin: 'kotlin-android-extensions'
android {
compileSdkVersion 29
defaultConfig {
applicationId "au.com.joshsharp.wishkobone"
minSdkVersion 21
targetSdkVersion 29
versionCode 1
versionName "1.0"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
}
buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}
tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile).all {
kotlinOptions {
jvmTarget = "1.8"
}
}
}
dependencies {
implementation fileTree(dir: "libs", include: ["*.jar"])
implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version"
implementation 'androidx.core:core-ktx:1.3.0'
implementation 'androidx.appcompat:appcompat:1.1.0'
implementation 'androidx.constraintlayout:constraintlayout:1.1.3'
implementation "com.squareup.okhttp3:okhttp:4.7.2"
implementation 'androidx.recyclerview:recyclerview:1.1.0'
implementation "io.coil-kt:coil:0.11.0"
implementation 'com.quiph.ui:recyclerviewfastscroller:0.1.3'
implementation 'com.google.android.material:material:1.1.0'
testImplementation 'junit:junit:4.12'
androidTestImplementation 'androidx.test.ext:junit:1.1.1'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.2.0'
}

21
proguard-rules.pro vendored Normal file
View file

@ -0,0 +1,21 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile

View file

@ -0,0 +1,24 @@
package au.com.joshsharp.wishkobone
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Test
import org.junit.runner.RunWith
import org.junit.Assert.*
/**
* Instrumented test, which will execute on an Android device.
*
* See [testing documentation](http://d.android.com/tools/testing).
*/
@RunWith(AndroidJUnit4::class)
class ExampleInstrumentedTest {
@Test
fun useAppContext() {
// Context of the app under test.
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
assertEquals("au.com.joshsharp.wishkobone", appContext.packageName)
}
}

View file

@ -0,0 +1,24 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="au.com.joshsharp.wishkobone">
<uses-permission android:name="android.permission.INTERNET" />
<application
android:name=".WishkoboneApplication"
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/AppTheme">
<activity android:name=".LoginActivity"></activity>
<activity android:name=".MainActivity">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>

View file

@ -0,0 +1,158 @@
package au.com.joshsharp.wishkobone;
import android.content.Context;
import android.util.Log;
import java.io.IOException;
import java.time.Duration;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import org.jetbrains.annotations.NotNull;
import okhttp3.Callback;
import okhttp3.Cookie;
import okhttp3.CookieJar;
import okhttp3.FormBody;
import okhttp3.HttpUrl;
import okhttp3.MediaType;
import okhttp3.OkHttpClient;
import okhttp3.Request;
import okhttp3.RequestBody;
import okhttp3.Response;
/**
* Created by Josh on 2/09/2014.
*/
public class APIClient {
private static final String BASE_URL = "https://www.kobo.com/";
private static OkHttpClient client;
private static String agent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:64.0) Gecko/20100101 Firefox/64.0";
private String cookieValue;
public APIClient() {
client = new OkHttpClient.Builder().retryOnConnectionFailure(false)
.build();
}
public void setCookie(String value){
cookieValue = value;
}
public void get(String url, HashMap<String, String> params, Callback responseHandler) {
HttpUrl.Builder urlBuilder = HttpUrl.parse(getAbsoluteUrl(url)).newBuilder();
if (params != null) {
for (String key : params.keySet()) {
urlBuilder = urlBuilder.setQueryParameter(key, params.get(key));
}
}
Request request = new Request.Builder().url(urlBuilder.build())
.get()
.addHeader("User-Agent", agent)
.addHeader("Referer", "https://www.kobo.com/au/en/account/wishlist")
.addHeader("Accept", "application / json, text / javascript, * / *; q = 0.01")
.addHeader("Accept-Language", "en-GB,en-AU;q=0.7,en;q=0.3")
.addHeader("X-Requested-With", "XMLHttpRequest")
.addHeader("Cookie", "KoboSession=" + cookieValue)
.build();
client.newCall(request).enqueue(responseHandler);
}
public void post(String url, @NotNull HashMap<String, String> params, Callback responseHandler) {
this.post(url, params, "application/x-www-form-urlencoded", responseHandler);
}
public void post(String url, @NotNull HashMap<String, String> params, String contentType, Callback responseHandler) {
FormBody.Builder builder = new FormBody.Builder();
for (String key : params.keySet()) {
builder = builder.add(key, params.get(key));
}
FormBody formBody = builder.build();
Request request = new Request.Builder().url(getAbsoluteUrl(url)).post(formBody)
.addHeader("User-Agent", agent)
.addHeader("Referer", "https://www.kobo.com/au/en/account/wishlist")
.addHeader("Accept", "application / json, text / javascript, * / *; q = 0.01")
.addHeader("Accept-Language", "en-GB,en-AU;q=0.7,en;q=0.3")
.addHeader("X-Requested-With", "XMLHttpRequest")
.addHeader("Cookie", "KoboSession=" + cookieValue)
.addHeader("Content-type", contentType)
.build();
client.newCall(request).enqueue(responseHandler);
}
public void post(String url, @NotNull String payload, String contentType, Callback responseHandler){
RequestBody body = RequestBody.create(payload, MediaType.get(contentType));
Request request = new Request.Builder().url(getAbsoluteUrl(url)).post(body)
.addHeader("User-Agent", agent)
.addHeader("Referer", "https://www.kobo.com/au/en/account/wishlist")
.addHeader("Accept", "application/json, text/javascript, */*; q=0.01")
.addHeader("Accept-Language", "en-GB,en-AU;q=0.7,en;q=0.3")
.addHeader("X-Requested-With", "XMLHttpRequest")
.addHeader("Origin","https://www.kobo.com")
.addHeader("Cookie", "KoboSession=" + cookieValue)
.addHeader("Content-type", contentType)
.build();
client.newCall(request).enqueue(responseHandler);
}
public Response syncGet(String url, HashMap<String, String> params) throws IOException {
HttpUrl.Builder urlBuilder = HttpUrl.parse(getAbsoluteUrl(url)).newBuilder();
if (params != null) {
for (String key : params.keySet()) {
urlBuilder = urlBuilder.setQueryParameter(key, params.get(key));
}
}
Request request = new Request.Builder().url(urlBuilder.build())
.get()
.addHeader("User-agent", agent)
.addHeader("Referer", "https://www.kobo.com/au/en/account/wishlist")
.addHeader("Accept", "application / json, text / javascript, * / *; q = 0.01")
.addHeader("Accept-Language", "en-GB,en-AU;q=0.7,en;q=0.3")
.addHeader("X-Requested-With", "XMLHttpRequest")
.addHeader("Cookie", "KoboSession=" + cookieValue)
.build();
return client.newCall(request).execute();
}
public Response syncPost(String url, @NotNull String payload, String contentType) throws IOException {
RequestBody body = RequestBody.create(payload, MediaType.get(contentType));
Request request = new Request.Builder().url(getAbsoluteUrl(url)).post(body)
.addHeader("User-agent", agent)
.addHeader("Referer", "https://www.kobo.com/au/en/account/wishlist")
.addHeader("Accept", "application/json, text/javascript, */*; q=0.01")
.addHeader("Accept-Language", "en-GB,en-AU;q=0.7,en;q=0.3")
.addHeader("X-Requested-With", "XMLHttpRequest")
.addHeader("Cookie", String.format("KoboSession=%s;", cookieValue))
.addHeader("Content-type", contentType)
.build();
return client.newCall(request).execute();
}
public Response syncPost(String url, @NotNull HashMap<String, String> params) throws IOException {
FormBody.Builder builder = new FormBody.Builder();
for (String key : params.keySet()) {
builder = builder.add(key, params.get(key));
}
FormBody formBody = builder.build();
return this.syncPost(url, formBody.toString(), "application/x-www-form-urlencoded");
}
private static String getAbsoluteUrl(String relativeUrl) {
return BASE_URL + relativeUrl;
}
}

View file

@ -0,0 +1,19 @@
package au.com.joshsharp.wishkobone
class Book(
var authors: ArrayList<String>,
var title: String,
var imageUrl: String,
var url: String,
var series: String?,
var synopsis: String,
var price: Float? = 0.0f
){
fun formattedPrice(): String {
if (this.price != null){
return String.format("$%.2f", this.price)
}
return "N/A"
}
}

View file

@ -0,0 +1,63 @@
package au.com.joshsharp.wishkobone;
import android.util.Log;
import org.jetbrains.annotations.NotNull;
import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;
import org.json.JSONTokener;
import java.io.IOException;
import okhttp3.Call;
import okhttp3.Callback;
import okhttp3.Response;
public abstract class JsonCallback implements Callback {
@Override
public void onResponse(@NotNull Call call, @NotNull Response response) throws IOException {
Object json;
try {
json = new JSONTokener(response.body().string()).nextValue();
response.body().close();
} catch (JSONException | NullPointerException e) {
throw new IOException(e);
}
if (json instanceof JSONArray) {
if (response.isSuccessful()) {
this.onResponse(call, response, ((JSONArray) json));
} else {
this.onFailureResponse(call, response, ((JSONArray) json));
}
} else if (json instanceof JSONObject) {
if (response.isSuccessful()) {
this.onResponse(call, response, ((JSONObject) json));
} else {
this.onFailureResponse(call, response, ((JSONObject) json));
}
} else {
this.onFailure(call, new IOException("Couldn't parse json"));
}
}
public void onResponse(@NotNull Call call, @NotNull Response r, @NotNull JSONObject response) {
// pass
Log.d("okhttp", "empty callback for JSONObject called");
}
public void onResponse(@NotNull Call call, @NotNull Response r, @NotNull JSONArray response) {
// pass
Log.d("okhttp", "empty callback for JSONArray called");
}
public void onFailureResponse(@NotNull Call call, @NotNull Response r, @NotNull JSONObject response) {
Log.d("okhttp", "empty failure callback for JSONObject called");
}
public void onFailureResponse(@NotNull Call call, @NotNull Response r, @NotNull JSONArray response) {
Log.d("okhttp", "empty failure callback for JSONArray called");
}
}

View file

@ -0,0 +1,31 @@
package au.com.joshsharp.wishkobone
import android.util.Log
import okhttp3.Cookie
import okhttp3.CookieJar
import okhttp3.HttpUrl
import java.util.ArrayList
class KoboCookieJar: CookieJar {
var list = ArrayList<Cookie>();
override fun loadForRequest(url: HttpUrl): List<Cookie> {
Log.d("cookies", "returning some cookies: ${list.joinToString(", ")}")
return list;
}
fun setCookie(value: String){
list.clear();
list.add(Cookie.Builder()
.name("KoboSession")
.domain("kobo.com")
.expiresAt(2092109000000)
.value(value)
.build());
}
override fun saveFromResponse(url: HttpUrl, cookies: List<Cookie>) {
// pass
}
}

View file

@ -0,0 +1,62 @@
package au.com.joshsharp.wishkobone
import android.annotation.SuppressLint
import android.os.Bundle
import android.util.Log
import android.webkit.CookieManager
import android.webkit.WebView
import android.webkit.WebViewClient
import androidx.appcompat.app.AppCompatActivity
import kotlinx.android.synthetic.main.activity_login.*
class LoginActivity : AppCompatActivity() {
@SuppressLint("SetJavaScriptEnabled")
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_login)
supportActionBar?.title = "Login to Kobo to continue"
val cookieManager = CookieManager.getInstance()
cookieManager.setAcceptCookie(true)
cookieManager.setAcceptThirdPartyCookies(web, true)
web.settings.javaScriptEnabled = true;
web.loadUrl("https://www.kobo.com/account/wishlist")
web.webViewClient = object: WebViewClient() {
override fun onPageFinished(view: WebView?, url: String?) {
super.onPageFinished(view, url)
Log.d("cookie", "now at ${url}")
Log.d("cookie",getCookie(".kobo.com","KoboSession") ?: "null");
val cookieVal = getCookie(".kobo.com","KoboSession")
if (cookieVal != null){
val app = application as WishkoboneApplication
app.setCookie(cookieVal)
this@LoginActivity.finish()
}
}
}
}
fun getCookie(siteName: String, name: String): String? {
val manager = CookieManager.getInstance()
manager.getCookie(siteName)?.let {cookies ->
val typedArray = cookies.split(";".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray()
for (element in typedArray) {
val split = element.split("=".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray()
if(split.size >= 2) {
if (split[0].trim() == name) {
return split[1]
}
}
}
}
return null
}
}

View file

@ -0,0 +1,328 @@
package au.com.joshsharp.wishkobone
import android.annotation.SuppressLint
import android.app.SearchManager
import android.content.Context
import android.content.Intent
import android.content.Intent.FLAG_ACTIVITY_NEW_TASK
import android.net.Uri
import android.os.Bundle
import android.text.Html
import android.util.Log
import android.view.LayoutInflater
import android.view.Menu
import android.view.View
import android.view.ViewGroup
import android.widget.ImageView
import android.widget.TextView
import android.widget.Toast
import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.widget.SearchView
import androidx.core.content.ContextCompat.startActivity
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import coil.api.load
import com.google.android.material.snackbar.BaseTransientBottomBar.LENGTH_SHORT
import com.google.android.material.snackbar.Snackbar
import com.qtalk.recyclerviewfastscroller.RecyclerViewFastScroller
import kotlinx.android.synthetic.main.activity_main.*
import okhttp3.Call
import okhttp3.Response
import org.json.JSONObject
import java.io.IOException
import java.util.*
import kotlin.collections.ArrayList
class MainActivity : AppCompatActivity() {
var hasCookie = false
val viewAdapter = BookAdapter(ArrayList())
var totalPages = 1
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
val viewManager = LinearLayoutManager(this)
status_text.text = "Loading..."
list.apply {
// 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
}
val app = application as WishkoboneApplication
val cookie = app.getCookie()
if (cookie != null) {
hasCookie = true
loadBooks()
} else {
doLogin()
}
}
fun doLogin(){
val login = Intent(this, LoginActivity::class.java)
startActivity(login);
}
override fun onResume() {
super.onResume()
if (!hasCookie) {
val app = application as WishkoboneApplication
val cookie = app.getCookie()
if (cookie != null) { // we just got this via login
loadBooks()
}
}
}
override fun onCreateOptionsMenu(menu: Menu): Boolean {
// Inflate the options menu from XML
val inflater = menuInflater
inflater.inflate(R.menu.menu_main, menu)
// Get the SearchView and set the searchable configuration
val searchManager = getSystemService(Context.SEARCH_SERVICE) as SearchManager
(menu.findItem(R.id.menu_search).actionView as SearchView).apply {
val view = this
// Assumes current activity is the searchable activity
setOnQueryTextListener(object : SearchView.OnQueryTextListener {
override fun onQueryTextSubmit(query: String?): Boolean {
view.clearFocus()
return true
}
override fun onQueryTextChange(newText: String?): Boolean {
viewAdapter.filter(newText)
return true
}
})
setIconifiedByDefault(true)
setOnCloseListener {
viewAdapter.filter(null) // reset
return@setOnCloseListener false
}
}
return true
}
@SuppressLint("SetTextI18n")
private fun loadBooks(page: Int = 1) {
runOnUiThread {
status.visibility = View.VISIBLE
status_text.text = "Loading page $page of $totalPages"
}
val app = application as WishkoboneApplication
app.client.post(
"account/wishlist/fetch",
"{\"pageIndex\":$page}",
"application/json",
object :
JsonCallback() {
override fun onFailure(call: Call, e: IOException) {
hasCookie = false
}
override fun onResponse(call: Call, r: Response, response: JSONObject) {
if (!response.has("TotalNumPages")){
// must be unauthenticated
hasCookie = false
runOnUiThread {
app.invalidateCookie()
doLogin()
}
return
}
totalPages = response.getInt("TotalNumPages")
val array = response.getJSONArray("Items")
for (i in 0 until array.length()) {
val obj = array.getJSONObject(i)
val authors = obj.getJSONArray("Authors")
val authorList = ArrayList<String>()
for (j in 0 until authors.length()) {
authorList.add(authors.getJSONObject(j).getString("Name"))
}
val price = obj.getString("Price").substring(1).toFloatOrNull()
var series: String? = null
if (obj.has("Series") && !obj.isNull("Series")) {
series = obj.getString("Series")
if (obj.has("SeriesNumber") && !obj.isNull("SeriesNumber")) {
series += " ${obj.getString("SeriesNumber")}"
}
}
runOnUiThread {
viewAdapter.books.add(
Book(
authorList,
obj.getString("Title"),
"https:" + obj.getString("ImageUrl"),
"http://www.kobo.com" + obj.getString("ProductUrl"),
series,
obj.getString("Synopsis"),
price
)
)
}
}
hasCookie = true
runOnUiThread {
viewAdapter.sortBooks()
}
if (array.length() == 12) {
// full page, probably worth going again
loadBooks(page + 1)
} else {
// the end!
runOnUiThread {
status_text.text =
"Load complete! ${viewAdapter.books.size} items shown."
status.postDelayed({
status.visibility = View.GONE
}, 3000)
}
}
}
override fun onFailureResponse(call: Call, r: Response, response: JSONObject) {
hasCookie = false
}
});
}
class BookAdapter(val books: ArrayList<Book>) :
RecyclerView.Adapter<BookAdapter.BookViewHolder>(),
RecyclerViewFastScroller.OnPopupTextUpdate {
// Provide a reference to the views for each data item
// Complex data items may need more than one view per item, and
// you provide access to all the views for a data item in a view holder.
// Each data item is just a string in this case that is shown in a TextView.
private var filteredBooks: ArrayList<Book> = ArrayList()
init {
filteredBooks.addAll(books)
}
class BookViewHolder(
val title: TextView,
val authors: TextView,
val price: TextView,
val series: TextView,
val synopsis: TextView,
val cover: ImageView,
itemView: View
) : RecyclerView.ViewHolder(
itemView
)
fun filter(query: String?) {
if (query == null) {
filteredBooks.clear()
filteredBooks.addAll(books)
} else {
Log.d("filter", "looking for $query")
filteredBooks = books.filter {
it.title.contains(query, true)
|| it.authors.joinToString(", ").contains(query, true)
|| it.series?.contains(query, true) ?: false
} as ArrayList<Book>
}
notifyDataSetChanged()
}
fun sortBooks() {
books.sortBy {
if (it.price != null) {
it.price
} else {
9999f
}
}
filteredBooks.clear()
filteredBooks.addAll(books)
notifyDataSetChanged()
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BookViewHolder {
val layout = LayoutInflater.from(parent.context)
.inflate(R.layout.book_item, parent, false) as ViewGroup
// set the view's size, margins, paddings and layout parameters
return BookViewHolder(
layout.findViewById(R.id.title), layout.findViewById(R.id.authors),
layout.findViewById(R.id.price), layout.findViewById(R.id.series),
layout.findViewById(R.id.synopsis), layout.findViewById(R.id.cover),
layout
)
}
override fun onBindViewHolder(holder: BookViewHolder, position: Int) {
val book = filteredBooks[position]
holder.title.text = book.title
holder.authors.text = book.authors.joinToString(", ")
if (book.price != null) {
holder.price.text = String.format("$%.2f", book.price)
} else {
holder.price.text = "N/A"
}
holder.synopsis.text = Html.fromHtml(book.synopsis).toString().trim()
if (book.series != null) {
holder.series.visibility = View.VISIBLE
holder.series.text = book.series
} else {
holder.series.visibility = View.GONE
}
holder.cover.load(book.imageUrl) {
crossfade(true)
}
holder.itemView.setOnClickListener {
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(book.url))
intent.addFlags(FLAG_ACTIVITY_NEW_TASK)
startActivity(it.context, intent, null)
}
}
override fun getItemCount(): Int {
return filteredBooks.size
}
override fun onChange(position: Int): CharSequence {
return filteredBooks[position].formattedPrice()
}
}
}

View file

@ -0,0 +1,37 @@
package au.com.joshsharp.wishkobone
import android.app.Application
import android.content.SharedPreferences
class WishkoboneApplication: Application() {
var client = APIClient()
override fun onCreate() {
super.onCreate()
if (getCookie() != null){
client.setCookie(getCookie())
}
}
fun getCookie(): String? {
val prefs = getSharedPreferences(getString(R.string.app_name), 0)
return prefs.getString("cookie",null)
}
fun setCookie(cookie: String) {
val editor = getSharedPreferences(getString(R.string.app_name), 0).edit()
editor.putString("cookie", cookie)
editor.apply()
client.setCookie(cookie)
}
fun invalidateCookie() {
val editor = getSharedPreferences(getString(R.string.app_name), 0).edit()
editor.clear()
editor.apply()
client = APIClient() // remove cookie
}
}

View file

@ -0,0 +1,30 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path android:pathData="M31,63.928c0,0 6.4,-11 12.1,-13.1c7.2,-2.6 26,-1.4 26,-1.4l38.1,38.1L107,108.928l-32,-1L31,63.928z">
<aapt:attr name="android:fillColor">
<gradient
android:endX="85.84757"
android:endY="92.4963"
android:startX="42.9492"
android:startY="49.59793"
android:type="linear">
<item
android:color="#44000000"
android:offset="0.0" />
<item
android:color="#00000000"
android:offset="1.0" />
</gradient>
</aapt:attr>
</path>
<path
android:fillColor="#FFFFFF"
android:fillType="nonZero"
android:pathData="M65.3,45.828l3.8,-6.6c0.2,-0.4 0.1,-0.9 -0.3,-1.1c-0.4,-0.2 -0.9,-0.1 -1.1,0.3l-3.9,6.7c-6.3,-2.8 -13.4,-2.8 -19.7,0l-3.9,-6.7c-0.2,-0.4 -0.7,-0.5 -1.1,-0.3C38.8,38.328 38.7,38.828 38.9,39.228l3.8,6.6C36.2,49.428 31.7,56.028 31,63.928h46C76.3,56.028 71.8,49.428 65.3,45.828zM43.4,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2c-0.3,-0.7 -0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C45.3,56.528 44.5,57.328 43.4,57.328L43.4,57.328zM64.6,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2s-0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C66.5,56.528 65.6,57.328 64.6,57.328L64.6,57.328z"
android:strokeWidth="1"
android:strokeColor="#00000000" />
</vector>

View file

@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:state_pressed="true">
<shape android:shape="rectangle">
<solid android:color="@color/colorAccent" />
<size android:width="10dp" android:height="40dp"/>
</shape>
</item>
<item>
<shape android:shape="rectangle">
<solid android:color="@color/lineGrey" />
<size android:width="10dp" android:height="40dp"/>
</shape>
</item>
</selector>

View file

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="#22000000" />
<size android:width="5dp"/>
</shape>

View file

@ -0,0 +1,3 @@
<?xml version="1.0" encoding="utf-8"?>
<color android:color="@color/colorPrimary"
xmlns:android="http://schemas.android.com/apk/res/android"/>

View file

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<corners android:bottomLeftRadius="32dp"
android:topLeftRadius="32dp"
android:topRightRadius="32dp"
android:bottomRightRadius="4dp"/>
<solid android:color="@color/colorAccent" />
</shape>

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<corners android:radius="4dp"/>
<solid android:color="@color/colorAccent" />
</shape>

View file

@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".LoginActivity">
<WebView
android:layout_width="match_parent"
android:layout_height="match_parent"
android:id="@+id/web"
/>
</androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -0,0 +1,42 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/coordinator"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".MainActivity">
<com.qtalk.recyclerviewfastscroller.RecyclerViewFastScroller
android:id="@+id/fastscroller"
android:layout_width="match_parent"
android:layout_height="match_parent"
app:popupDrawable="@drawable/scroller_popup"
app:handleDrawable="@drawable/scroller_thumb"
app:layout_constraintBottom_toBottomOf="@id/coordinator"
app:handleHeight="32dp">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/list"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:scrollbars="none" />
</com.qtalk.recyclerviewfastscroller.RecyclerViewFastScroller>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:id="@+id/status"
app:layout_constraintBottom_toBottomOf="@id/coordinator"
android:orientation="horizontal"
android:background="@color/colorPrimaryDark"
android:paddingVertical="12dp"
android:paddingHorizontal="15dp">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textColor="@android:color/white"
android:id="@+id/status_text"
android:textSize="15sp"
/>
</LinearLayout>
</androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -0,0 +1,82 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:paddingHorizontal="15dp"
android:paddingVertical="12dp">
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="15dp"
android:orientation="vertical">
<TextView
android:id="@+id/price"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginVertical="5dp"
android:textColor="@color/colorPrimaryDark"
android:textSize="16sp"
android:textAlignment="viewEnd"
android:layout_marginBottom="12dp"
android:includeFontPadding="false"
android:textStyle="bold" />
<ImageView
android:id="@+id/cover"
android:layout_width="90dp"
android:layout_height="wrap_content"
android:scaleType="fitStart"
android:adjustViewBounds="true"
android:background="@color/lineGrey"
/>
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<TextView
android:id="@+id/title"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textSize="18sp"
android:textStyle="bold" />
<TextView
android:id="@+id/authors"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textSize="17sp" />
<TextView
android:id="@+id/series"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textAllCaps="true"
android:textColor="@color/secondaryGrey"
android:textSize="15sp" />
<TextView
android:layout_marginTop="8dp"
android:id="@+id/synopsis"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textSize="15sp" />
</LinearLayout>
</LinearLayout>
<View
android:layout_width="match_parent"
android:layout_height="1dp"
android:background="@color/lineGrey" />
</LinearLayout>

View file

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:android="http://schemas.android.com/apk/res/android">
<item android:id="@+id/menu_search"
android:title="Search"
app:showAsAction="always"
app:actionViewClass="androidx.appcompat.widget.SearchView"
/>
</menu>

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@mipmap/logo" />
</adaptive-icon>

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@mipmap/logo" />
</adaptive-icon>

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

View file

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="colorPrimary">#bf2026</color>
<color name="colorPrimaryDark">#ab1d22</color>
<color name="colorAccent">#4218bb</color>
<color name="lineGrey">#e0e0e0</color>
<color name="secondaryGrey">#888888</color>
</resources>

View file

@ -0,0 +1,3 @@
<resources>
<string name="app_name">Wishkobone</string>
</resources>

View file

@ -0,0 +1,11 @@
<resources>
<!-- Base application theme. -->
<style name="AppTheme" parent="Theme.AppCompat.Light.DarkActionBar">
<!-- Customize your theme here. -->
<item name="colorPrimary">@color/colorPrimary</item>
<item name="colorPrimaryDark">@color/colorPrimaryDark</item>
<item name="colorAccent">@color/colorAccent</item>
<item name="android:textColor">#333333</item>
</style>
</resources>

View file

@ -0,0 +1,17 @@
package au.com.joshsharp.wishkobone
import org.junit.Test
import org.junit.Assert.*
/**
* Example local unit test, which will execute on the development machine (host).
*
* See [testing documentation](http://d.android.com/tools/testing).
*/
class ExampleUnitTest {
@Test
fun addition_isCorrect() {
assertEquals(4, 2 + 2)
}
}