diff --git a/src/all/tachidesk/AndroidManifest.xml b/src/all/tachidesk/AndroidManifest.xml
new file mode 100644
index 000000000..30deb7f79
--- /dev/null
+++ b/src/all/tachidesk/AndroidManifest.xml
@@ -0,0 +1,2 @@
+
+
diff --git a/src/all/tachidesk/build.gradle b/src/all/tachidesk/build.gradle
new file mode 100644
index 000000000..f62575335
--- /dev/null
+++ b/src/all/tachidesk/build.gradle
@@ -0,0 +1,14 @@
+apply plugin: 'com.android.application'
+apply plugin: 'kotlin-android'
+apply plugin: 'kotlinx-serialization'
+
+ext {
+ extName = 'Tachidesk'
+ pkgNameSuffix = 'all.tachidesk'
+ extClass = '.Tachidesk'
+ extVersionCode = 1
+ libVersion = '1.2'
+ containsNsfw = true
+}
+
+apply from: "$rootDir/common.gradle"
diff --git a/src/all/tachidesk/res/mipmap-hdpi/ic_launcher.png b/src/all/tachidesk/res/mipmap-hdpi/ic_launcher.png
new file mode 100644
index 000000000..0fdfde363
Binary files /dev/null and b/src/all/tachidesk/res/mipmap-hdpi/ic_launcher.png differ
diff --git a/src/all/tachidesk/res/mipmap-mdpi/ic_launcher.png b/src/all/tachidesk/res/mipmap-mdpi/ic_launcher.png
new file mode 100644
index 000000000..5de574929
Binary files /dev/null and b/src/all/tachidesk/res/mipmap-mdpi/ic_launcher.png differ
diff --git a/src/all/tachidesk/res/mipmap-xhdpi/ic_launcher.png b/src/all/tachidesk/res/mipmap-xhdpi/ic_launcher.png
new file mode 100644
index 000000000..7247e859a
Binary files /dev/null and b/src/all/tachidesk/res/mipmap-xhdpi/ic_launcher.png differ
diff --git a/src/all/tachidesk/res/mipmap-xxhdpi/ic_launcher.png b/src/all/tachidesk/res/mipmap-xxhdpi/ic_launcher.png
new file mode 100644
index 000000000..70fa84350
Binary files /dev/null and b/src/all/tachidesk/res/mipmap-xxhdpi/ic_launcher.png differ
diff --git a/src/all/tachidesk/res/mipmap-xxxhdpi/ic_launcher.png b/src/all/tachidesk/res/mipmap-xxxhdpi/ic_launcher.png
new file mode 100644
index 000000000..ebfad819e
Binary files /dev/null and b/src/all/tachidesk/res/mipmap-xxxhdpi/ic_launcher.png differ
diff --git a/src/all/tachidesk/res/web_hi_res_512.png b/src/all/tachidesk/res/web_hi_res_512.png
new file mode 100644
index 000000000..e138f8ca9
Binary files /dev/null and b/src/all/tachidesk/res/web_hi_res_512.png differ
diff --git a/src/all/tachidesk/src/eu/kanade/tachiyomi/extension/all/tachidesk/Dto.kt b/src/all/tachidesk/src/eu/kanade/tachiyomi/extension/all/tachidesk/Dto.kt
new file mode 100644
index 000000000..b121475cd
--- /dev/null
+++ b/src/all/tachidesk/src/eu/kanade/tachiyomi/extension/all/tachidesk/Dto.kt
@@ -0,0 +1,73 @@
+package eu.kanade.tachiyomi.extension.all.tachidesk
+
+import kotlinx.serialization.Serializable
+
+@Serializable
+data class SourceDataClass(
+ val id: String,
+ val name: String?,
+ val lang: String?,
+ val iconUrl: String?,
+ val supportsLatest: Boolean?,
+ val isConfigurable: Boolean?
+)
+
+@Serializable
+data class MangaDataClass(
+ val id: Int,
+ val sourceId: String,
+
+ val url: String,
+ val title: String,
+ val thumbnailUrl: String? = null,
+
+ val initialized: Boolean = false,
+
+ val artist: String? = null,
+ val author: String? = null,
+ val description: String? = null,
+ val genre: String? = null,
+ val status: String = "UNKNOWN",
+ val inLibrary: Boolean = false,
+ val source: SourceDataClass? = null,
+ val meta: Map = emptyMap(),
+
+ val freshData: Boolean = false
+)
+
+@Serializable
+data class ChapterDataClass(
+ val url: String,
+ val name: String,
+ val uploadDate: Long,
+ val chapterNumber: Float,
+ val scanlator: String?,
+ val mangaId: Int,
+
+ /** chapter is read */
+ val read: Boolean,
+
+ /** chapter is bookmarked */
+ val bookmarked: Boolean,
+
+ /** last read page, zero means not read/no data */
+ val lastPageRead: Int,
+
+ /** last read page, zero means not read/no data */
+ val lastReadAt: Long,
+
+ /** this chapter's index, starts with 1 */
+ val index: Int,
+
+ /** is chapter downloaded */
+ val downloaded: Boolean,
+
+ /** used to construct pages in the front-end */
+ val pageCount: Int = -1,
+
+ /** total chapter count, used to calculate if there's a next and prev chapter */
+ val chapterCount: Int? = null,
+
+ /** used to store client specific values */
+ val meta: Map = emptyMap(),
+)
diff --git a/src/all/tachidesk/src/eu/kanade/tachiyomi/extension/all/tachidesk/Tachidesk.kt b/src/all/tachidesk/src/eu/kanade/tachiyomi/extension/all/tachidesk/Tachidesk.kt
new file mode 100644
index 000000000..7c7161ec2
--- /dev/null
+++ b/src/all/tachidesk/src/eu/kanade/tachiyomi/extension/all/tachidesk/Tachidesk.kt
@@ -0,0 +1,180 @@
+package eu.kanade.tachiyomi.extension.all.tachidesk
+
+import android.app.Application
+import android.content.SharedPreferences
+import android.text.InputType
+import android.util.Log
+import android.widget.Toast
+import androidx.preference.EditTextPreference
+import androidx.preference.PreferenceScreen
+import eu.kanade.tachiyomi.network.GET
+import eu.kanade.tachiyomi.network.asObservableSuccess
+import eu.kanade.tachiyomi.source.ConfigurableSource
+import eu.kanade.tachiyomi.source.model.FilterList
+import eu.kanade.tachiyomi.source.model.MangasPage
+import eu.kanade.tachiyomi.source.model.Page
+import eu.kanade.tachiyomi.source.model.SChapter
+import eu.kanade.tachiyomi.source.model.SManga
+import eu.kanade.tachiyomi.source.online.HttpSource
+import kotlinx.serialization.decodeFromString
+import kotlinx.serialization.json.Json
+import okhttp3.Request
+import okhttp3.Response
+import rx.Observable
+import uy.kohesive.injekt.Injekt
+import uy.kohesive.injekt.api.get
+import uy.kohesive.injekt.injectLazy
+import java.lang.RuntimeException
+
+class Tachidesk : ConfigurableSource, HttpSource() {
+ override val name = "Tachidesk"
+ override val baseUrl by lazy { getPrefBaseUrl() }
+ override val lang = "en"
+ override val supportsLatest = false
+
+ private val json: Json by injectLazy()
+
+ // ------------- Popular Manga -------------
+
+ override fun popularMangaRequest(page: Int): Request =
+ GET("$checkedBaseUrl/api/v1/library")
+
+ override fun popularMangaParse(response: Response): MangasPage =
+ MangasPage(
+ json.decodeFromString>(response.body!!.string()).map {
+ it.toSManga()
+ },
+ false
+ )
+ // ------------- Manga Details -------------
+
+ override fun mangaDetailsRequest(manga: SManga) =
+ GET("$checkedBaseUrl/api/v1/manga/${manga.url}/?onlineFetch=true")
+
+ override fun mangaDetailsParse(response: Response): SManga =
+ json.decodeFromString(response.body!!.string()).let { it.toSManga() }
+
+ // ------------- Chapter -------------
+
+ override fun chapterListRequest(manga: SManga): Request =
+ GET("$checkedBaseUrl/api/v1/manga/${manga.url}/chapters?onlineFetch=true", headers)
+
+ override fun chapterListParse(response: Response): List =
+ json.decodeFromString>(response.body!!.string()).map {
+ it.toSChapter()
+ }
+
+ // ------------- Page List -------------
+
+ override fun fetchPageList(chapter: SChapter): Observable> {
+ return client.newCall(pageListRequest(chapter))
+ .asObservableSuccess()
+ .map { response ->
+ pageListParse(response, chapter)
+ }
+ }
+
+ override fun pageListRequest(chapter: SChapter): Request {
+ val mangaId = chapter.url.split(" ").first()
+ val chapterIndex = chapter.url.split(" ").last()
+
+ return GET("$checkedBaseUrl/api/v1/manga/$mangaId/chapter/$chapterIndex/?onlineFetch=True", headers)
+ }
+
+ fun pageListParse(response: Response, sChapter: SChapter): List {
+ val mangaId = sChapter.url.split(" ").first()
+ val chapterIndex = sChapter.url.split(" ").last()
+
+ val chapter = json.decodeFromString(response.body!!.string())
+
+ return List(chapter.pageCount) {
+ Page(it + 1, "", "$checkedBaseUrl/api/v1/manga/$mangaId/chapter/$chapterIndex/page/$it/")
+ }
+ }
+
+ // ------------- Preferences -------------
+ override fun setupPreferenceScreen(screen: PreferenceScreen) {
+ screen.addPreference(screen.editTextPreference(ADDRESS_TITLE, ADDRESS_DEFAULT, baseUrl))
+ }
+
+ /** boilerplate for [EditTextPreference] */
+ private fun PreferenceScreen.editTextPreference(title: String, default: String, value: String, isPassword: Boolean = false): EditTextPreference {
+ return EditTextPreference(context).apply {
+ key = title
+ this.title = title
+ summary = value
+ this.setDefaultValue(default)
+ dialogTitle = title
+
+ if (isPassword) {
+ setOnBindEditTextListener {
+ it.inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD
+ }
+ }
+
+ setOnPreferenceChangeListener { _, newValue ->
+ try {
+ val res = preferences.edit().putString(title, newValue as String).commit()
+ Toast.makeText(context, "Restart Tachiyomi to apply new setting.", Toast.LENGTH_LONG).show()
+ res
+ } catch (e: Exception) {
+ Log.e("Tachidesk", "Exception while setting text preference", e)
+ false
+ }
+ }
+ }
+ }
+
+ private val preferences: SharedPreferences by lazy {
+ Injekt.get().getSharedPreferences("source_$id", 0x0000)
+ }
+
+ private fun getPrefBaseUrl(): String = preferences.getString(ADDRESS_TITLE, ADDRESS_DEFAULT)!!
+
+ companion object {
+ private const val ADDRESS_TITLE = "Address"
+ private const val ADDRESS_DEFAULT = ""
+ }
+
+ // ------------- Not Used -------------
+
+ override fun latestUpdatesRequest(page: Int): Request = throw Exception("Not used")
+
+ override fun latestUpdatesParse(response: Response): MangasPage = throw Exception("Not used")
+
+ override fun searchMangaRequest(page: Int, query: String, filters: FilterList) = throw Exception("Not used")
+
+ override fun searchMangaParse(response: Response): MangasPage = throw Exception("Not used")
+
+ override fun pageListParse(response: Response): List = throw Exception("Not used")
+
+ override fun imageUrlParse(response: Response): String = throw Exception("Not used")
+
+ // ------------- Util -------------
+
+ private fun MangaDataClass.toSManga() = SManga.create().also {
+ it.title = title
+ it.url = id.toString()
+ it.thumbnail_url = "$baseUrl$thumbnailUrl"
+ it.artist = artist
+ it.author = author
+ it.description = description
+ it.status = when (status) {
+ "ONGOING" -> SManga.ONGOING
+ "COMPLETED" -> SManga.COMPLETED
+ "LICENSED" -> SManga.LICENSED
+ else -> SManga.UNKNOWN // covers "UNKNOWN" and other Impossible cases
+ }
+ }
+
+ private fun ChapterDataClass.toSChapter() = SChapter.create().also {
+ it.url = "$mangaId $index"
+ it.name = name
+ it.date_upload = uploadDate
+ it.scanlator = scanlator
+ }
+
+ private val checkedBaseUrl: String
+ get(): String = if (baseUrl.isNotEmpty()) baseUrl
+ else throw RuntimeException("Set Tachidesk server url in extension settings")
+}