Skip to content

Commit

Permalink
Add Anki pages host activity and server
Browse files Browse the repository at this point in the history
This introduces a dependency to NanoHTTPD, a java server library

Used krmanik#23 as base of this commit
  • Loading branch information
BrayanDSO committed Aug 20, 2022
1 parent db83770 commit d84428c
Show file tree
Hide file tree
Showing 8 changed files with 269 additions and 1 deletion.
1 change: 1 addition & 0 deletions AnkiDroid/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -264,6 +264,7 @@ dependencies {
implementation 'com.google.android.material:material:1.6.1'
implementation 'com.fasterxml.jackson.core:jackson-databind:2.13.3'
implementation 'com.github.CanHub:Android-Image-Cropper:4.3.1'
implementation 'org.nanohttpd:nanohttpd:2.3.1'

// Backend libraries

Expand Down
5 changes: 5 additions & 0 deletions AnkiDroid/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,11 @@
android:exported="false"
android:configChanges="keyboardHidden|locale|orientation|screenSize|uiMode"
/>
<activity
android:name="com.ichi2.anki.pages.PagesActivity"
android:configChanges="keyboardHidden|orientation|screenSize|locale|uiMode"
android:exported="true"
android:theme="@android:style/Theme.Translucent.NoTitleBar"/>
<activity
android:name="com.ichi2.anki.IntentHandler"
android:configChanges="keyboardHidden|orientation|screenSize|locale|uiMode"
Expand Down
99 changes: 99 additions & 0 deletions AnkiDroid/src/main/java/com/ichi2/anki/pages/AnkiServer.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
/*
* Copyright (c) 2022 Mani <[email protected]>
* Copyright (c) 2022 Brayan Oliveira <[email protected]>
*
* This program is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License as published by the Free Software
* Foundation; either version 3 of the License, or (at your option) any later
* version.
*
* This program is distributed in the hope that it will be useful, but WITHOUT ANY
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A
* PARTICULAR PURPOSE. See the GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License along with
* this program. If not, see <http://www.gnu.org/licenses/>.
*/

package com.ichi2.anki.pages

import com.ichi2.libanki.*
import com.ichi2.libanki.importer.getCsvMetadataRaw
import com.ichi2.libanki.importer.importCsvRaw
import com.ichi2.libanki.stats.*
import fi.iki.elonen.NanoHTTPD
import timber.log.Timber
import java.io.ByteArrayInputStream

class AnkiServer(
hostname: String?,
port: Int,
val col: CollectionV16,
val activity: PagesActivity
) : NanoHTTPD(hostname, port) {

override fun serve(session: IHTTPSession): Response {
val uri = session.uri
val mime = getMimeFromUri(uri)

if (session.method == Method.GET) {
Timber.d("GET: Requested %s", uri)
return newChunkedResponse(Response.Status.OK, mime, this.javaClass.classLoader!!.getResourceAsStream("web$uri"))
}

if (session.method == Method.POST) {
Timber.d("POST: Requested %s", uri)
val bytes = getSessionBytes(session)
if (uri.startsWith(ANKI_PREFIX)) {
val data: ByteArray? = when (uri.substring(ANKI_PREFIX.length)) {
"i18nResources" -> col.i18nResourcesRaw(bytes)
"getGraphPreferences" -> col.getGraphPreferencesRaw()
"setGraphPreferences" -> col.setGraphPreferencesRaw(bytes)
"graphs" -> col.graphsRaw(bytes)
"getNotetypeNames" -> col.getNotetypeNamesRaw(bytes)
"getDeckNames" -> col.getDeckNamesRaw(bytes)
"getCsvMetadata" -> col.getCsvMetadataRaw(bytes)
"importCsv" -> col.importCsvRaw(bytes)
"getFieldNames" -> col.getFieldNamesRaw(bytes)
"cardStats" -> col.cardStatsRaw(bytes)
else -> { Timber.w("Unhandled Anki request: %s", uri); null }
}
return newChunkedResponse(data)
}
}
return newFixedLengthResponse(null)
}

private fun getSessionBytes(session: IHTTPSession): ByteArray {
val contentLength = session.headers["content-length"]!!.toInt()
val bytes = ByteArray(contentLength)
session.inputStream.read(bytes, 0, contentLength)
return bytes
}

companion object {
const val ANKI_PREFIX = "/_anki/"

fun getMimeFromUri(uri: String): String {
return when (uri.substringAfterLast(".")) {
"ico" -> "image/x-icon"
"css" -> "text/css"
"js" -> "text/javascript"
"html" -> "text/html"
else -> "application/binary"
}
}

fun newChunkedResponse(
data: ByteArray?,
mimeType: String = "application/binary",
status: Response.IStatus = Response.Status.OK
): Response {
return if (data == null) {
newFixedLengthResponse(null)
} else {
newChunkedResponse(status, mimeType, ByteArrayInputStream(data))
}
}
}
}
13 changes: 13 additions & 0 deletions AnkiDroid/src/main/java/com/ichi2/anki/pages/CsvImporter.kt
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@
*/
package com.ichi2.anki.pages

import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.webkit.WebView
import com.ichi2.anki.R
Expand Down Expand Up @@ -47,5 +49,16 @@ class CsvImporter : PageFragment() {

/** Key of [CsvImporter]'s argument that holds the path of the file to be imported */
private const val ARG_KEY_PATH = "csvPath"

/**
* @param filePath path of the csv file that will be imported, which should be accessible by AnkiDroid
* @return an intent to open the [CsvImporter] page on [PagesActivity]
*/
fun getIntent(context: Context, filePath: String): Intent {
val arguments = Bundle().apply {
putString(ARG_KEY_PATH, filePath)
}
return PagesActivity.getIntent(context, PAGE_NAME, arguments)
}
}
}
11 changes: 11 additions & 0 deletions AnkiDroid/src/main/java/com/ichi2/anki/pages/PageFragment.kt
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@ import android.webkit.WebView
import androidx.annotation.StringRes
import androidx.fragment.app.Fragment
import com.ichi2.anki.R
import com.ichi2.anki.pages.PagesActivity.Companion.HOST_NAME
import com.ichi2.themes.Themes
import timber.log.Timber

/**
* Base class for displaying Anki HTML pages
Expand All @@ -37,6 +40,9 @@ abstract class PageFragment : Fragment() {

lateinit var webView: WebView

val port
get() = (requireActivity() as PagesActivity).port

override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
Expand All @@ -48,6 +54,11 @@ abstract class PageFragment : Fragment() {
settings.javaScriptEnabled = true
webViewClient = this@PageFragment.webViewClient
}
val nightMode = if (Themes.currentTheme.isNightMode) "#night" else ""
val url = "http://$HOST_NAME:$port/$pageName.html$nightMode"

Timber.i("Loading $url")
webView.loadUrl(url)

return view
}
Expand Down
115 changes: 115 additions & 0 deletions AnkiDroid/src/main/java/com/ichi2/anki/pages/PagesActivity.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
/*
* Copyright (c) 2022 Brayan Oliveira <[email protected]>
*
* This program is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License as published by the Free Software
* Foundation; either version 3 of the License, or (at your option) any later
* version.
*
* This program is distributed in the hope that it will be useful, but WITHOUT ANY
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A
* PARTICULAR PURPOSE. See the GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License along with
* this program. If not, see <http://www.gnu.org/licenses/>.
*/
package com.ichi2.anki.pages

import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.webkit.WebView
import androidx.fragment.app.commit
import com.ichi2.anki.*
import com.ichi2.libanki.CollectionV16
import timber.log.Timber
import java.net.ServerSocket

/**
* Container activity to host Anki HTML pages
* * Responsibilities:
* * Serve as parent activity of the [PageFragment] that holds the page
* * Host an [AnkiServer] to intercept any requests made by an Anki page and resolve them
* * Operate UI requests by the [AnkiServer]
*/
class PagesActivity : AnkiActivity() {
private lateinit var ankiServer: AnkiServer

/** Port used by [ankiServer]. Normally the first available port at the moment this is instantiated */
val port = ServerSocket(0).use { socket -> socket.localPort }

override fun onCreate(savedInstanceState: Bundle?) {
if (showedActivityFailedScreen(savedInstanceState)) {
return
}
super.onCreate(savedInstanceState)
setContentView(R.layout.page_activity)
enableToolbar()

// Enable debugging on DEBUG builds
WebView.setWebContentsDebuggingEnabled(BuildConfig.DEBUG)

// Load server
ankiServer = AnkiServer(HOST_NAME, port, col as CollectionV16, this)
Timber.i("Starting server on $HOST_NAME:$port")
ankiServer.start()

// Launch page
val pageName = intent.extras?.getString(EXTRA_PAGE_NAME)
?: throw Exception("PageActivity's intent should have a '$EXTRA_PAGE_NAME' extra")

val pageFragment = getPageFragment(pageName).apply {
arguments = intent.getBundleExtra(EXTRA_PAGE_ARGS)
}
supportFragmentManager.commit {
replace(R.id.page_container, pageFragment, pageName)
}
setTitle(pageFragment.title)
}

override fun onDestroy() {
super.onDestroy()
/** Stop running the server if the activity is destroyed.
* The initialization check is for the case [showedActivityFailedScreen] is true */
if (this::ankiServer.isInitialized && ankiServer.isAlive) {
ankiServer.stop()
}
}

/**
* @return the [PageFragment] whose name is equal to [pageName]
* @throws Exception if there is not a page associated with the given [pageName]
*/
private fun getPageFragment(pageName: String): PageFragment {
return when (pageName) {
CsvImporter.PAGE_NAME -> CsvImporter()
else -> throw Exception("'$pageName' page doesn't have a PageFragment associated")
}
}

companion object {
/**
* Extra key of [PagesActivity]'s intent that can be used to pass a [Bundle]
* as arguments of the [PageFragment] that will be opened
*/
const val EXTRA_PAGE_ARGS = "pageArgs"
/**
* Extra key of [PagesActivity]'s intent that must be included and
* hold the name of an [Anki HTML page](https://github.com/ankitects/anki/tree/main/ts)
*/
const val EXTRA_PAGE_NAME = "pageName"

const val HOST_NAME = "127.0.0.1"

/**
* @param pageName name of the Anki HTML page that should be opened
* @param arguments to be passed to the created [PageFragment]
*/
fun getIntent(context: Context, pageName: String, arguments: Bundle? = null): Intent {
return Intent(context, PagesActivity::class.java).apply {
putExtra(EXTRA_PAGE_NAME, pageName)
putExtra(EXTRA_PAGE_ARGS, arguments)
}
}
}
}
22 changes: 22 additions & 0 deletions AnkiDroid/src/main/res/layout/page_activity.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
<?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/root_layout"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".pages.PagesActivity">

<include layout="@layout/toolbar" />

<androidx.fragment.app.FragmentContainerView
android:id="@+id/page_container"
android:layout_width="0dp"
android:layout_height="0dp"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toBottomOf="@id/toolbar"
app:layout_constraintBottom_toBottomOf="parent" />

</androidx.constraintlayout.widget.ConstraintLayout>
4 changes: 3 additions & 1 deletion AnkiDroid/src/test/java/com/ichi2/testutils/ActivityList.kt
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import com.ichi2.anki.CardTemplateBrowserAppearanceEditor.Companion.INTENT_ANSWE
import com.ichi2.anki.CardTemplateBrowserAppearanceEditor.Companion.INTENT_QUESTION_FORMAT
import com.ichi2.anki.multimediacard.activity.LoadPronunciationActivity
import com.ichi2.anki.multimediacard.activity.MultimediaEditFieldActivity
import com.ichi2.anki.pages.PagesActivity
import com.ichi2.anki.services.ReminderService.Companion.getReviewDeckIntent
import com.ichi2.testutils.ActivityList.ActivityLaunchParam.Companion.get
import com.ichi2.utils.KotlinCleanup
Expand Down Expand Up @@ -70,7 +71,8 @@ object ActivityList {
get(CardInfo::class.java),
get(CardTemplateEditor::class.java) { intentForCardTemplateEditor() },
get(CardTemplateBrowserAppearanceEditor::class.java) { intentForCardTemplateBrowserAppearanceEditor() },
get(SharedDecksActivity::class.java)
get(SharedDecksActivity::class.java),
get(PagesActivity::class.java)
)
}

Expand Down

0 comments on commit d84428c

Please sign in to comment.