From e1243518084b71c694d80b5217920369bae2ac33 Mon Sep 17 00:00:00 2001 From: "Darryl L. Pierce" Date: Sun, 23 Jun 2024 11:33:51 -0400 Subject: [PATCH] Added loading a server's root listing [#7] * Added a logging subsystem for all platforms. * Added a dependency on Ktor to do the networking. * Changed Android to allow clear text HTTP requests. --- androidVariant/src/main/AndroidManifest.xml | 5 +- .../variant/android/VariantApp.kt | 2 +- .../variant/android/ui/NavigationScreen.kt | 63 +++++++++ .../variant/android/ui/server/BottomBar.kt | 68 ++++++++++ .../variant/android/ui/server/HomeScreen.kt | 113 ++++++++++------ .../variant/android/ui/server/ServerBrowse.kt | 124 ++++++++++++++++++ .../variant/android/ui/server/ServerDetail.kt | 70 +--------- .../variant/android/ui/server/ServerEdit.kt | 2 + .../ui/server/ServerManagementScreen.kt | 18 +-- .../src/main/res/values/strings.xml | 5 +- .../main/res/xml/network_security_config.xml | 8 ++ gradle/libs.versions.toml | 18 +++ .../iosVariant.xcodeproj/project.pbxproj | 26 ++-- .../Model/VariantViewModelWrapper.swift | 1 + .../iosVariant/Supporting Files/Koin.swift | 52 ++++---- iosVariant/iosVariant/iOSApp.swift | 1 + shared/build.gradle.kts | 10 ++ .../variant/shared/platform/Logger.android.kt | 25 ++-- .../variant/shared/KoinCommon.kt | 10 +- .../comixedproject/variant/shared/Values.kt | 22 ++++ .../variant/shared/data/DatabaseHelper.kt | 36 ++++- .../variant/shared/data/FeedAPI.kt | 77 +++++++++++ .../variant/shared/data/HttpClientLogger.kt | 29 ++++ .../variant/shared/data/LinkRepository.kt | 43 ++++++ .../variant/shared/data/OpdsFeedData.kt | 50 +++++++ .../variant/shared/domain/GetFeedData.kt | 79 +++++++++++ .../variant/shared/domain/LinkFeedData.kt | 31 +++++ .../variant/shared/domain/ServiceLocator.kt | 26 ++++ .../variant/shared/model/VariantViewModel.kt | 59 ++++++++- .../variant/shared/model/server/Link.kt | 12 ++ .../variant/shared/platform/Logger.kt | 41 ++++++ .../shared/presentation/FeedPresenter.kt | 43 ++++++ .../org/comixedproject/variant/db/Table.sq | 27 +++- .../comixedproject/variant/shared/KoinIOS.kt | 7 +- .../variant/shared/platform/Logger.ios.kt | 35 +++++ 35 files changed, 1052 insertions(+), 186 deletions(-) create mode 100644 androidVariant/src/main/java/org/comixedproject/variant/android/ui/NavigationScreen.kt create mode 100644 androidVariant/src/main/java/org/comixedproject/variant/android/ui/server/BottomBar.kt create mode 100644 androidVariant/src/main/java/org/comixedproject/variant/android/ui/server/ServerBrowse.kt create mode 100644 androidVariant/src/main/res/xml/network_security_config.xml rename androidVariant/src/main/java/org/comixedproject/variant/android/ui/Screens.kt => shared/src/androidMain/kotlin/org/comixedproject/variant/shared/platform/Logger.android.kt (54%) create mode 100644 shared/src/commonMain/kotlin/org/comixedproject/variant/shared/Values.kt create mode 100644 shared/src/commonMain/kotlin/org/comixedproject/variant/shared/data/FeedAPI.kt create mode 100644 shared/src/commonMain/kotlin/org/comixedproject/variant/shared/data/HttpClientLogger.kt create mode 100644 shared/src/commonMain/kotlin/org/comixedproject/variant/shared/data/LinkRepository.kt create mode 100644 shared/src/commonMain/kotlin/org/comixedproject/variant/shared/data/OpdsFeedData.kt create mode 100644 shared/src/commonMain/kotlin/org/comixedproject/variant/shared/domain/GetFeedData.kt create mode 100644 shared/src/commonMain/kotlin/org/comixedproject/variant/shared/domain/LinkFeedData.kt create mode 100644 shared/src/commonMain/kotlin/org/comixedproject/variant/shared/domain/ServiceLocator.kt create mode 100644 shared/src/commonMain/kotlin/org/comixedproject/variant/shared/model/server/Link.kt create mode 100644 shared/src/commonMain/kotlin/org/comixedproject/variant/shared/platform/Logger.kt create mode 100644 shared/src/commonMain/kotlin/org/comixedproject/variant/shared/presentation/FeedPresenter.kt create mode 100644 shared/src/iosMain/kotlin/org/comixedproject/variant/shared/platform/Logger.ios.kt diff --git a/androidVariant/src/main/AndroidManifest.xml b/androidVariant/src/main/AndroidManifest.xml index 8055cbb..6046cd2 100644 --- a/androidVariant/src/main/AndroidManifest.xml +++ b/androidVariant/src/main/AndroidManifest.xml @@ -1,12 +1,15 @@ + + + */ + +package org.comixedproject.variant.android.ui + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.List +import androidx.compose.material.icons.filled.AccountBox +import androidx.compose.material.icons.filled.Settings +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.navigation.NamedNavArgument +import androidx.navigation.NavType +import androidx.navigation.navArgument +import org.comixedproject.variant.android.R + +enum class NavigationScreen(val route: String, val navArguments: List) { + ComicList("comics", emptyList()), + Servers("servers", emptyList()), + BrowserServer( + "servers?serverId={serverId}&linkId={linkId}", listOf( + navArgument("serverId") { + type = NavType.StringType + }, + navArgument("linkId") { + type = NavType.StringType + nullable = true + }) + ), + Settings("settings", emptyList()); + + companion object { + val all = values() + } +} + +enum class BottomBarItems(val label: Int, val icon: ImageVector, val screen: NavigationScreen) { + ServerList(R.string.serverButtonLabel, Icons.Filled.AccountBox, NavigationScreen.Servers), + ComicList( + R.string.comicsButtonLabel, + Icons.AutoMirrored.Filled.List, + NavigationScreen.ComicList + ), + Settings(R.string.settingsButtonLabel, Icons.Filled.Settings, NavigationScreen.Settings); + + companion object { + val all = values() + } +} \ No newline at end of file diff --git a/androidVariant/src/main/java/org/comixedproject/variant/android/ui/server/BottomBar.kt b/androidVariant/src/main/java/org/comixedproject/variant/android/ui/server/BottomBar.kt new file mode 100644 index 0000000..91d9604 --- /dev/null +++ b/androidVariant/src/main/java/org/comixedproject/variant/android/ui/server/BottomBar.kt @@ -0,0 +1,68 @@ +/* + * Variant - A digital comic book reading application for the iPad and Android tablets. + * Copyright (C) 2024, The ComiXed Project + * + * 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 + */ + +package org.comixedproject.variant.android.ui.server + +import androidx.compose.material3.Icon +import androidx.compose.material3.NavigationBar +import androidx.compose.material3.NavigationBarItem +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.navigation.NavDestination +import org.comixedproject.variant.android.VariantTheme +import org.comixedproject.variant.android.ui.BottomBarItems + + +@Composable +fun BottomBar( + currentDestination: NavDestination?, + onScreenChange: (route: String) -> Unit +) { + var selectedItem by remember { mutableIntStateOf(0) } + + NavigationBar { + BottomBarItems.all.forEachIndexed { index, item -> + NavigationBarItem(selected = selectedItem == index, + onClick = { + selectedItem = index + onScreenChange(item.screen.route) + }, + label = { Text(text = stringResource(id = item.label)) }, + icon = { + Icon( + imageVector = item.icon, + contentDescription = stringResource(id = item.label) + ) + }) + } + } +} + +@Preview +@Composable +fun BottomBarPreview() { + VariantTheme { + BottomBar(null, onScreenChange = {}) + } +} \ No newline at end of file diff --git a/androidVariant/src/main/java/org/comixedproject/variant/android/ui/server/HomeScreen.kt b/androidVariant/src/main/java/org/comixedproject/variant/android/ui/server/HomeScreen.kt index 5d9d222..2af249f 100644 --- a/androidVariant/src/main/java/org/comixedproject/variant/android/ui/server/HomeScreen.kt +++ b/androidVariant/src/main/java/org/comixedproject/variant/android/ui/server/HomeScreen.kt @@ -18,67 +18,94 @@ package org.comixedproject.variant.android.ui.server -import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.material3.Icon -import androidx.compose.material3.Surface +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Scaffold import androidx.compose.material3.Text -import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteScaffold import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.compose.rememberNavController import org.comixedproject.variant.android.VariantTheme -import org.comixedproject.variant.android.ui.Screens +import org.comixedproject.variant.android.ui.NavigationScreen import org.comixedproject.variant.shared.model.VariantViewModel +import org.comixedproject.variant.shared.platform.Logger import org.koin.androidx.compose.getViewModel +private const val TAG = "HomeScreen" + @Composable fun HomeScreen(viewModel: VariantViewModel = getViewModel()) { - val selectedItem = remember { mutableStateOf(Screens.ComicManagement) } - val navHost = rememberNavController() - val navBackStackEntry by navHost.currentBackStackEntryAsState() - val currentDestination = navBackStackEntry?.destination + val showBottomBar = remember { mutableStateOf(true) } + val navController = rememberNavController() - NavigationSuiteScaffold( - navigationSuiteItems = { - Screens.all.forEach { screen -> - item( - selected = selectedItem.value == screen, - onClick = { selectedItem.value = screen }, - label = { Text(stringResource(id = screen.label)) }, - icon = { - Icon( - imageVector = screen.icon, - contentDescription = stringResource(id = screen.label) - ) - }, - alwaysShowLabel = true - ) + Scaffold( + topBar = { Text("Top Bar") }, + bottomBar = { + if (showBottomBar.value) { + BottomBar(currentDestination = navController.currentBackStackEntryAsState().value?.destination, + onScreenChange = { route -> navController.navigate(route) }) } - } - ) { - Box(modifier = Modifier.fillMaxSize()) { - Surface(modifier = Modifier.align(Alignment.Center)) { - when (selectedItem.value) { - Screens.ServerManagement -> ServerManagementScreen( - viewModel.servers, - onSaveServer = { server -> - viewModel.saveServer(server) + }, + ) { padding -> + Column( + modifier = Modifier + .padding(padding) + .fillMaxSize() + ) { + NavHost( + modifier = Modifier, + navController = navController, + startDestination = NavigationScreen.Servers.route + ) { + composable( + route = NavigationScreen.Servers.route + ) { + ServerManagementScreen( + servers = viewModel.servers, + onSaveServer = { server -> viewModel.saveServer(server) }, + onBrowseServer = { server -> + navController.navigate("servers?serverId=${server.id}&linkId=") }, - onBrowserServer = {}, - onDeleteServer = { server -> - viewModel.deleteServer(server) - }) - - Screens.ComicManagement -> Text("Comic Book Management") - Screens.Settings -> Text("Settings") + onDeleteServer = { } + ) + } + composable( + route = NavigationScreen.BrowserServer.route, + arguments = NavigationScreen.BrowserServer.navArguments + ) { entry -> + val serverId = entry.arguments?.getString("serverId") + val linkId = entry.arguments?.getString("linkId").orEmpty() + Logger.d(TAG, "serverId=${serverId} linkId=${linkId}") + viewModel.servers.find { server -> server.id == serverId }?.let { server -> + var directory = "" + if (linkId.length > 0) { + viewModel.allLinks.find { link -> link.id == linkId }?.let { link -> + directory = link.link + } + } + viewModel.loadServerFeed(server, directory) + ServerBrowse( + server = server, + links = viewModel.links, + directory, + onLoadDirectory = { server, selectedLink -> + viewModel.loadServerFeed(server, selectedLink.link) + navController.navigate("servers?serverId=${server.id}&linkId=${selectedLink.id}") + }) + } + } + composable(route = NavigationScreen.ComicList.route) { + Text("Comic List!") + } + composable(route = NavigationScreen.Settings.route) { + Text("Settings!") } } } diff --git a/androidVariant/src/main/java/org/comixedproject/variant/android/ui/server/ServerBrowse.kt b/androidVariant/src/main/java/org/comixedproject/variant/android/ui/server/ServerBrowse.kt new file mode 100644 index 0000000..f934b45 --- /dev/null +++ b/androidVariant/src/main/java/org/comixedproject/variant/android/ui/server/ServerBrowse.kt @@ -0,0 +1,124 @@ +/* + * Variant - A digital comic book reading application for the iPad and Android tablets. + * Copyright (C) 2024, The ComiXed Project + * + * 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 + */ + +package org.comixedproject.variant.android.ui.server + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.material3.Button +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults.topAppBarColors +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import org.comixedproject.variant.android.VariantTheme +import org.comixedproject.variant.shared.model.server.Link +import org.comixedproject.variant.shared.model.server.Server + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ServerBrowse( + server: Server, + links: List, + directory: String, + onLoadDirectory: (Server, Link) -> Unit +) { + Scaffold( + topBar = { + TopAppBar( + colors = topAppBarColors( + containerColor = MaterialTheme.colorScheme.primaryContainer, + titleContentColor = MaterialTheme.colorScheme.primary, + ), + title = { + Text(text = server.name) + }, + navigationIcon = { + IconButton( + onClick = { }) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Go back" + ) + } + } + ) + } + ) { padding -> + Column(modifier = Modifier.padding(padding)) { + Text("${server.url}${directory}") + Text("Show ${links.size} entries") + links.forEach { link -> + Row { + Button(onClick = { onLoadDirectory(server, link) }) { + Icon(imageVector = Icons.Filled.PlayArrow, contentDescription = link.title) + } + Text("[${link.title}]") + } + } + } + } +} + +@Preview +@Composable +fun ServerBrowsePreviewRoot() { + VariantTheme { + ServerBrowse( + server = Server( + "1", + "My Server", + "http://www.comixedproject.org:7171/opds", + "reader@comixedproject.org", + "my!password" + ), + mutableListOf(), + "", + onLoadDirectory = { server, directory -> } + ) + } +} + +@Preview +@Composable +fun ServerBrowsePreviewChild() { + VariantTheme { + ServerBrowse( + server = Server( + "1", + "My Server", + "http://www.comixedproject.org:7171/opds", + "reader@comixedproject.org", + "my!password" + ), + mutableListOf(), + "first/second/third", + onLoadDirectory = { server, directory -> } + ) + } +} \ No newline at end of file diff --git a/androidVariant/src/main/java/org/comixedproject/variant/android/ui/server/ServerDetail.kt b/androidVariant/src/main/java/org/comixedproject/variant/android/ui/server/ServerDetail.kt index d26f5c7..cd38acd 100644 --- a/androidVariant/src/main/java/org/comixedproject/variant/android/ui/server/ServerDetail.kt +++ b/androidVariant/src/main/java/org/comixedproject/variant/android/ui/server/ServerDetail.kt @@ -18,72 +18,15 @@ package org.comixedproject.variant.android.ui.server -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.padding -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Delete -import androidx.compose.material.icons.filled.Edit -import androidx.compose.material.icons.filled.Search -import androidx.compose.material3.BottomAppBar -import androidx.compose.material3.Button -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import org.comixedproject.variant.android.R import org.comixedproject.variant.android.VariantTheme import org.comixedproject.variant.shared.model.server.Server @Composable -fun ServerDetail( - server: Server, - onEdit: () -> Unit, - onDelete: () -> Unit, - onBrowse: () -> Unit -) { - Scaffold(bottomBar = { - BottomAppBar( - containerColor = MaterialTheme.colorScheme.primaryContainer, - contentColor = MaterialTheme.colorScheme.primary - ) { - Button(onClick = onEdit) { - Icon( - imageVector = Icons.Filled.Edit, - contentDescription = stringResource(id = R.string.editButtonLabel) - ) - } - Button(onClick = onBrowse) { - Icon( - imageVector = Icons.Filled.Search, - contentDescription = stringResource(id = R.string.browseButtonLabel) - ) - } - Button(onClick = onDelete) { - Icon( - imageVector = Icons.Filled.Delete, - contentDescription = stringResource(id = R.string.deleteButtonLabel) - ) - } - } - }) { padding -> - Box(modifier = Modifier.padding(padding)) { - Column(modifier = Modifier.padding(32.dp)) { - Text(text = server.name, style = MaterialTheme.typography.titleLarge) - Text(text = server.url, style = MaterialTheme.typography.bodyMedium) - Text(text = server.username, style = MaterialTheme.typography.bodyMedium) - Text( - text = server.password.replace(".".toRegex(), "*"), - style = MaterialTheme.typography.bodyMedium - ) - } - } - } +fun ServerDetail(server: Server) { + Text(server.name) } @Preview @@ -93,10 +36,11 @@ fun ServerDetailPreview() { ServerDetail( server = Server( "1", - "My Server", + "Server 1", "http://www.comixedproject.org:7171/opds", - "reader@comixedproject.org", - "my!password" - ), onEdit = {}, onDelete = {}, onBrowse = {}) + "reader@comixedprojecvt.org", + "password" + ) + ) } } \ No newline at end of file diff --git a/androidVariant/src/main/java/org/comixedproject/variant/android/ui/server/ServerEdit.kt b/androidVariant/src/main/java/org/comixedproject/variant/android/ui/server/ServerEdit.kt index c521b05..0811cb2 100644 --- a/androidVariant/src/main/java/org/comixedproject/variant/android/ui/server/ServerEdit.kt +++ b/androidVariant/src/main/java/org/comixedproject/variant/android/ui/server/ServerEdit.kt @@ -63,12 +63,14 @@ fun ServerEdit(server: Server, onSave: (Server) -> Unit, onCancel: () -> Unit) { imageVector = Icons.Filled.Check, contentDescription = stringResource(id = R.string.saveButtonLabel) ) + Text(text = stringResource(id = R.string.saveButton)) } Button(onClick = onCancel) { Icon( imageVector = Icons.Filled.Close, contentDescription = stringResource(id = R.string.cancelButtonLabel) ) + Text(text = stringResource(id = R.string.closeButton)) } } }) { padding -> diff --git a/androidVariant/src/main/java/org/comixedproject/variant/android/ui/server/ServerManagementScreen.kt b/androidVariant/src/main/java/org/comixedproject/variant/android/ui/server/ServerManagementScreen.kt index 1c3c011..f7d2e69 100644 --- a/androidVariant/src/main/java/org/comixedproject/variant/android/ui/server/ServerManagementScreen.kt +++ b/androidVariant/src/main/java/org/comixedproject/variant/android/ui/server/ServerManagementScreen.kt @@ -34,7 +34,7 @@ import org.comixedproject.variant.shared.model.server.Server fun ServerManagementScreen( servers: List, onSaveServer: (Server) -> Unit, - onBrowserServer: (Server) -> Unit, + onBrowseServer: (Server) -> Unit, onDeleteServer: (Server) -> Unit ) { val navigator = rememberListDetailPaneScaffoldNavigator() @@ -55,9 +55,7 @@ fun ServerManagementScreen( Server(null, "", "", "", "") ) }, - onServerSelect = { server -> - navigator.navigateTo(ListDetailPaneScaffoldRole.Detail, server) - }, + onServerSelect = onBrowseServer, onServerEdit = { server -> navigator.navigateTo( ListDetailPaneScaffoldRole.Extra, @@ -69,13 +67,7 @@ fun ServerManagementScreen( }, detailPane = { navigator.currentDestination?.content?.let { server -> - ServerDetail(server = server, onEdit = { - navigator.navigateTo(ListDetailPaneScaffoldRole.Extra, server) - }, onBrowse = { - onBrowserServer(server) - }, onDelete = { - onDeleteServer(server) - }) + ServerDetail(server = server) } }, extraPane = { @@ -84,7 +76,7 @@ fun ServerManagementScreen( server = server, onSave = { server -> onSaveServer(server) - navigator.navigateTo(ListDetailPaneScaffoldRole.List) + navigator.navigateBack() }, onCancel = { navigator.navigateBack() }) } @@ -130,7 +122,7 @@ fun ServerManagementScreenPreview() { ) ), onSaveServer = {}, - onBrowserServer = {}, + onBrowseServer = {}, onDeleteServer = {} ) } diff --git a/androidVariant/src/main/res/values/strings.xml b/androidVariant/src/main/res/values/strings.xml index ada7354..88e3aa0 100644 --- a/androidVariant/src/main/res/values/strings.xml +++ b/androidVariant/src/main/res/values/strings.xml @@ -11,9 +11,8 @@ Your password Save changes Cancel action - Delete this entry - Browser server contents - Edit this entry Edit this server Delete this server + Save + Cancel \ No newline at end of file diff --git a/androidVariant/src/main/res/xml/network_security_config.xml b/androidVariant/src/main/res/xml/network_security_config.xml new file mode 100644 index 0000000..d7b4192 --- /dev/null +++ b/androidVariant/src/main/res/xml/network_security_config.xml @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index d7f45fb..b01eac2 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -15,6 +15,8 @@ adaptive-navigation-android = "1.3.0-beta02" navigation-compose = "2.7.7" koin = "3.5.6" sqldelight-plugin = "2.0.2" +ktor = "2.3.12" +korio = "4.0.10" [libraries] kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" } @@ -39,6 +41,8 @@ androidx-compose-ui-ui-test-junit4 = { module = "androidx.compose.ui:ui-test-jun androidx-espresso-core = { module = "androidx.test.espresso:espresso-core", version.ref = "espresso-core" } androidx-junit = { module = "androidx.test.ext:junit", version.ref = "androidx-junit" } junit = { module = "junit:junit", version.ref = "junit" } +khttp = { group = "org.danilopianini", name = "khttp", version = "1.6.3" } +korio = { group = "com.soywiz.korlibs.korio", name = "korio", version.ref = "korio" } # koin koin-core = { group = "io.insert-koin", name = "koin-core", version.ref = "koin" } @@ -50,6 +54,17 @@ koin-androidx-compose = { group = "io.insert-koin", name = "koin-androidx-compos sqldelight-driver-android = { group = "app.cash.sqldelight", name = "android-driver", version.ref = "sqldelight-plugin" } sqldelight-driver-native = { group = "app.cash.sqldelight", name = "native-driver", version.ref = "sqldelight-plugin" } +# Ktor +ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" } +ktor-client-content-negotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor" } +ktor-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" } +ktor-client-serialization = { module = "io.ktor:ktor-client-serialization", version.ref = "ktor" } +ktor-client-android = { module = "io.ktor:ktor-client-android", version.ref = "ktor" } +ktor-client-ios = { module = "io.ktor:ktor-client-ios", version.ref = "ktor" } +ktor-client-logging = { module = "io.ktor:ktor-client-logging", version.ref = "ktor" } +ktor-client-mock = { module = "io.ktor:ktor-client-mock", version.ref = "ktor" } +ktor-client-auth = { module = "io.ktor:ktor-client-auth", version.ref = "ktor" } + [plugins] android-application = { id = "com.android.application", version.ref = "android-gradle-plugin" } android-library = { id = "com.android.library", version.ref = "android-gradle-plugin" } @@ -60,6 +75,9 @@ sqldelight = { id = "app.cash.sqldelight", version.ref = "sqldelight-plugin" } [bundles] androidx = ["androidx-activity-compose", "androidx-core-ktx", "androidx-lifecycle-runtime-ktx", "koin-android", "koin-androidx-compose"] +opds-shared = ["khttp"] +opds-android = ["khttp"] +opds-ios = ["khttp"] compose = ["androidx-compose-material3", "androidx-compose-ui-ui-graphics", "androidx-compose-ui-ui", "androidx-compose-ui-ui-tooling-preview", "androidx-adaptive", "androidx-adaptive-layout", "androidx-adaptive-navigation", "androidx-adaptive-navigation-suite-android", "androidx-navigation-compose"] compose-debug = ["androidx-compose-ui-ui-tooling", "androidx-compose-ui-ui-test-manifest"] instrumented-tests = ["androidx-junit", "androidx-espresso-core", "androidx-compose-ui-ui-test-junit4"] diff --git a/iosVariant/iosVariant.xcodeproj/project.pbxproj b/iosVariant/iosVariant.xcodeproj/project.pbxproj index 60f7fb3..2e0ec36 100644 --- a/iosVariant/iosVariant.xcodeproj/project.pbxproj +++ b/iosVariant/iosVariant.xcodeproj/project.pbxproj @@ -11,9 +11,9 @@ 058557D9273AAEEB004C7B11 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 058557D8273AAEEB004C7B11 /* Preview Assets.xcassets */; }; 2152FB042600AC8F00CF470E /* iOSApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2152FB032600AC8F00CF470E /* iOSApp.swift */; }; 7555FF83242A565900829871 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7555FF82242A565900829871 /* ContentView.swift */; }; + B60624CF2C31BD90002AE74C /* Koin.swift in Sources */ = {isa = PBXBuildFile; fileRef = B60624CE2C31BD90002AE74C /* Koin.swift */; }; B67A1D102C14D67F0099E845 /* HomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B67A1D0F2C14D67F0099E845 /* HomeView.swift */; }; B68D9C022C1CD7930017D3D6 /* VariantViewModelWrapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = B68D9C012C1CD7930017D3D6 /* VariantViewModelWrapper.swift */; }; - B68D9C052C1CE54D0017D3D6 /* Koin.swift in Sources */ = {isa = PBXBuildFile; fileRef = B68D9C042C1CE54D0017D3D6 /* Koin.swift */; }; B69A97352C153A62003D094D /* ServerListItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = B69A97342C153A62003D094D /* ServerListItem.swift */; }; B69A97372C153CCC003D094D /* ServerManagementView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B69A97362C153CCC003D094D /* ServerManagementView.swift */; }; B69A97392C154273003D094D /* ServerDetail.swift in Sources */ = {isa = PBXBuildFile; fileRef = B69A97382C154273003D094D /* ServerDetail.swift */; }; @@ -40,9 +40,9 @@ 7555FF7B242A565900829871 /* iosVariant.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = iosVariant.app; sourceTree = BUILT_PRODUCTS_DIR; }; 7555FF82242A565900829871 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; 7555FF8C242A565B00829871 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + B60624CE2C31BD90002AE74C /* Koin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Koin.swift; sourceTree = ""; }; B67A1D0F2C14D67F0099E845 /* HomeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeView.swift; sourceTree = ""; }; B68D9C012C1CD7930017D3D6 /* VariantViewModelWrapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VariantViewModelWrapper.swift; sourceTree = ""; }; - B68D9C042C1CE54D0017D3D6 /* Koin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Koin.swift; sourceTree = ""; }; B69A97342C153A62003D094D /* ServerListItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerListItem.swift; sourceTree = ""; }; B69A97362C153CCC003D094D /* ServerManagementView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerManagementView.swift; sourceTree = ""; }; B69A97382C154273003D094D /* ServerDetail.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerDetail.swift; sourceTree = ""; }; @@ -88,7 +88,7 @@ 7555FF7D242A565900829871 /* iosVariant */ = { isa = PBXGroup; children = ( - B68D9C032C1CE53E0017D3D6 /* Supporting Files */, + B60624CD2C31BD88002AE74C /* Supporting Files */, B68D9C002C1CD76C0017D3D6 /* Model */, B69A97312C153A07003D094D /* Servers */, B67A1D0C2C14D51C0099E845 /* Home */, @@ -108,6 +108,14 @@ name = Frameworks; sourceTree = ""; }; + B60624CD2C31BD88002AE74C /* Supporting Files */ = { + isa = PBXGroup; + children = ( + B60624CE2C31BD90002AE74C /* Koin.swift */, + ); + path = "Supporting Files"; + sourceTree = ""; + }; B67A1D0C2C14D51C0099E845 /* Home */ = { isa = PBXGroup; children = ( @@ -124,14 +132,6 @@ path = Model; sourceTree = ""; }; - B68D9C032C1CE53E0017D3D6 /* Supporting Files */ = { - isa = PBXGroup; - children = ( - B68D9C042C1CE54D0017D3D6 /* Koin.swift */, - ); - path = "Supporting Files"; - sourceTree = ""; - }; B69A97312C153A07003D094D /* Servers */ = { isa = PBXGroup; children = ( @@ -235,6 +235,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + B60624CF2C31BD90002AE74C /* Koin.swift in Sources */, B6B28AF42C1DCF5A00171449 /* ServerEdit.swift in Sources */, B69A97372C153CCC003D094D /* ServerManagementView.swift in Sources */, 2152FB042600AC8F00CF470E /* iOSApp.swift in Sources */, @@ -242,7 +243,6 @@ B69A97352C153A62003D094D /* ServerListItem.swift in Sources */, B67A1D102C14D67F0099E845 /* HomeView.swift in Sources */, 7555FF83242A565900829871 /* ContentView.swift in Sources */, - B68D9C052C1CE54D0017D3D6 /* Koin.swift in Sources */, B69A97392C154273003D094D /* ServerDetail.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -386,7 +386,7 @@ OTHER_LDFLAGS = ( "$(inherited)", "-framework", - shared, + Variant, ); PRODUCT_BUNDLE_IDENTIFIER = orgIdentifier.iosVariant; PRODUCT_NAME = "$(TARGET_NAME)"; diff --git a/iosVariant/iosVariant/Model/VariantViewModelWrapper.swift b/iosVariant/iosVariant/Model/VariantViewModelWrapper.swift index 205aca7..11d2d54 100644 --- a/iosVariant/iosVariant/Model/VariantViewModelWrapper.swift +++ b/iosVariant/iosVariant/Model/VariantViewModelWrapper.swift @@ -25,6 +25,7 @@ final class VariantViewModelWrapper { let viewModel: VariantViewModel = Koin.instance.get() private(set) var servers: [Server] = [] + private(set) var links: [Link] = [] init() { viewModel.onServerUpdate = { [weak self] servers in self?.servers = servers } diff --git a/iosVariant/iosVariant/Supporting Files/Koin.swift b/iosVariant/iosVariant/Supporting Files/Koin.swift index dbb49ac..5c2293a 100644 --- a/iosVariant/iosVariant/Supporting Files/Koin.swift +++ b/iosVariant/iosVariant/Supporting Files/Koin.swift @@ -19,31 +19,33 @@ import Variant final class Koin { - private var core: Koin_coreKoin? - - static let instance = Koin() - - static func start() { - if instance.core == nil { - let app = KoinIOS.shared.initialize( - userDefaults: UserDefaults.standard - ) - instance.core = app.koin - } - if instance.core == nil { - fatalError("Failed to initialize Koin.") - } + private var core: Koin_coreKoin? + + static let instance = Koin() + + static func start() { + if instance.core == nil { + let app = KoinIOS.shared.initialize() + instance.core = app.koin } - - private init() {} - - func get() -> T { - guard let core else { - fatalError("You should call `start()` before using \(#function)") - } - - guard let result = core.get(objCClass: T.self) as? T else { fatalError("Koin can't provide an instance of type: \(T.self)") } - - return result + if instance.core == nil { + fatalError("Can't initial Koin.") } + } + + private init() { + + } + + func get() -> T { + guard let core else { + fatalError("You should call `start()` before using \(#function)") + } + + guard let result = core.get(objCClass: T.self) as? T else { + fatalError("Koin can't provide an instance of type: \(T.self)") + } + + return result + } } diff --git a/iosVariant/iosVariant/iOSApp.swift b/iosVariant/iosVariant/iOSApp.swift index 0db0098..39f5bec 100644 --- a/iosVariant/iosVariant/iOSApp.swift +++ b/iosVariant/iosVariant/iOSApp.swift @@ -17,6 +17,7 @@ */ import SwiftUI +import Variant @available(iOS 17.0, *) @main diff --git a/shared/build.gradle.kts b/shared/build.gradle.kts index cbd1890..95ee460 100644 --- a/shared/build.gradle.kts +++ b/shared/build.gradle.kts @@ -27,16 +27,26 @@ kotlin { androidMain.dependencies { implementation(libs.lifecycle.viewmodel.android) implementation(libs.sqldelight.driver.android) + implementation(libs.ktor.client.android) } iosMain.dependencies { implementation(libs.sqldelight.driver.native) + implementation(libs.ktor.client.ios) } commonMain.dependencies { implementation(libs.koin.core) + implementation(libs.ktor.client.core) + implementation(libs.ktor.client.serialization) + implementation(libs.ktor.client.content.negotiation) + implementation(libs.ktor.client.logging) + implementation(libs.ktor.serialization.kotlinx.json) + implementation(libs.ktor.client.auth) + implementation(libs.korio) } commonTest.dependencies { implementation(libs.kotlin.test) implementation(libs.koin.test) + implementation(libs.ktor.client.mock) } } diff --git a/androidVariant/src/main/java/org/comixedproject/variant/android/ui/Screens.kt b/shared/src/androidMain/kotlin/org/comixedproject/variant/shared/platform/Logger.android.kt similarity index 54% rename from androidVariant/src/main/java/org/comixedproject/variant/android/ui/Screens.kt rename to shared/src/androidMain/kotlin/org/comixedproject/variant/shared/platform/Logger.android.kt index c4ceaff..a312ceb 100644 --- a/androidVariant/src/main/java/org/comixedproject/variant/android/ui/Screens.kt +++ b/shared/src/androidMain/kotlin/org/comixedproject/variant/shared/platform/Logger.android.kt @@ -16,21 +16,20 @@ * along with this program. If not, see */ -package org.comixedproject.variant.android.ui +package org.comixedproject.variant.shared.platform -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.AccountBox -import androidx.compose.material.icons.filled.List -import androidx.compose.material.icons.filled.Settings -import androidx.compose.ui.graphics.vector.ImageVector -import org.comixedproject.variant.android.R +import android.util.Log -enum class Screens(val label: Int, val icon: ImageVector) { - ComicManagement(R.string.comicsButtonLabel, Icons.Filled.List), - ServerManagement(R.string.serverButtonLabel, Icons.Filled.AccountBox), - Settings(R.string.settingsButtonLabel, Icons.Filled.Settings); +internal actual class Log { + actual fun debug(tag: String, message: String) { + Log.d(tag, message) + } + + actual fun warn(tag: String, message: String) { + Log.w(tag, message) + } - companion object { - val all = values() + actual fun error(tag: String, message: String) { + Log.e(tag, message) } } \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/comixedproject/variant/shared/KoinCommon.kt b/shared/src/commonMain/kotlin/org/comixedproject/variant/shared/KoinCommon.kt index 675918e..163fdf8 100644 --- a/shared/src/commonMain/kotlin/org/comixedproject/variant/shared/KoinCommon.kt +++ b/shared/src/commonMain/kotlin/org/comixedproject/variant/shared/KoinCommon.kt @@ -19,6 +19,7 @@ package org.comixedproject.variant.shared import org.comixedproject.variant.shared.data.DatabaseHelper +import org.comixedproject.variant.shared.data.LinkRepository import org.comixedproject.variant.shared.data.ServerRepository import org.comixedproject.variant.shared.model.VariantViewModel import org.koin.core.KoinApplication @@ -32,11 +33,16 @@ object Modules { } val repositories = module { - factory { ServerRepository(get()) } + factory { + ServerRepository(get()) + } + factory { + LinkRepository(get()) + } } val viewModels = module { - factory { VariantViewModel(get()) } + factory { VariantViewModel(get(), get()) } } } diff --git a/shared/src/commonMain/kotlin/org/comixedproject/variant/shared/Values.kt b/shared/src/commonMain/kotlin/org/comixedproject/variant/shared/Values.kt new file mode 100644 index 0000000..fa28172 --- /dev/null +++ b/shared/src/commonMain/kotlin/org/comixedproject/variant/shared/Values.kt @@ -0,0 +1,22 @@ +/* + * Variant - A digital comic book reading application for the iPad and Android tablets. + * Copyright (C) 2024, The ComiXed Project + * + * 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 + */ + +package org.comixedproject.variant.shared + +public const val X_APP_NAME = "X-App-Name" +public const val APP_NAME = "Variant" \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/comixedproject/variant/shared/data/DatabaseHelper.kt b/shared/src/commonMain/kotlin/org/comixedproject/variant/shared/data/DatabaseHelper.kt index c3e9e12..a65d6d7 100644 --- a/shared/src/commonMain/kotlin/org/comixedproject/variant/shared/data/DatabaseHelper.kt +++ b/shared/src/commonMain/kotlin/org/comixedproject/variant/shared/data/DatabaseHelper.kt @@ -20,8 +20,10 @@ package org.comixedproject.variant.shared.data import app.cash.sqldelight.db.SqlDriver import org.comixedproject.variant.VariantDb +import org.comixedproject.variant.db.LinksDb import org.comixedproject.variant.db.ServersDb import org.comixedproject.variant.shared.IDGenerator +import org.comixedproject.variant.shared.model.server.Link class DatabaseHelper(sqlDriver: SqlDriver) { private val database: VariantDb = VariantDb(sqlDriver) @@ -29,7 +31,13 @@ class DatabaseHelper(sqlDriver: SqlDriver) { fun loadServers(): List = database.tableQueries.loadAllServers().executeAsList() fun createServer(name: String, url: String, username: String, password: String) { - database.tableQueries.createServer(IDGenerator().toString(), name, url, username, password) + database.tableQueries.createServer( + IDGenerator().toString(), + name, + url, + username, + password + ) } fun updateServer(id: String, name: String, url: String, username: String, password: String) { @@ -39,4 +47,30 @@ class DatabaseHelper(sqlDriver: SqlDriver) { fun deleteServer(id: String) { database.tableQueries.deleteServer(id) } + + fun loadAllLinks(): List = database.tableQueries.loadAllLinks().executeAsList() + + fun loadLinks(serverId: String, directory: String): List = + database.tableQueries.loadLinksForParent(serverId, directory) + .executeAsList() + + fun saveLinksForServer(serverId: String, directory: String, links: List) { + val incomingPaths = links.map { it.link } + val existingLinks = + database.tableQueries.loadLinksForParent(serverId, directory).executeAsList() + existingLinks.filter { link -> !incomingPaths.contains(link.link) } + .forEach { link -> database.tableQueries.deleteExistingLink(link.id) } + val existingPaths = existingLinks.map { it.link } + links.filter { !existingPaths.contains(it.link) }.forEach { link -> + database.tableQueries.createLink( + IDGenerator().toString(), + serverId, + link.linkId, + link.directory, + link.link, + link.title, + link.thumbnailURL.orEmpty() + ) + } + } } \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/comixedproject/variant/shared/data/FeedAPI.kt b/shared/src/commonMain/kotlin/org/comixedproject/variant/shared/data/FeedAPI.kt new file mode 100644 index 0000000..8e2fca2 --- /dev/null +++ b/shared/src/commonMain/kotlin/org/comixedproject/variant/shared/data/FeedAPI.kt @@ -0,0 +1,77 @@ +/* + * Variant - A digital comic book reading application for the iPad and Android tablets. + * Copyright (C) 2024, The ComiXed Project + * + * 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 + */ + +package org.comixedproject.variant.shared.data + +import io.ktor.client.HttpClient +import io.ktor.client.call.body +import io.ktor.client.plugins.auth.Auth +import io.ktor.client.plugins.auth.providers.BasicAuthCredentials +import io.ktor.client.plugins.auth.providers.basic +import io.ktor.client.plugins.contentnegotiation.ContentNegotiation +import io.ktor.client.plugins.logging.LogLevel +import io.ktor.client.plugins.logging.Logging +import io.ktor.client.request.get +import io.ktor.client.request.header +import io.ktor.client.statement.HttpResponse +import io.ktor.serialization.kotlinx.json.json +import korlibs.io.net.URL +import kotlinx.serialization.json.Json +import org.comixedproject.variant.shared.APP_NAME +import org.comixedproject.variant.shared.X_APP_NAME +import org.comixedproject.variant.shared.model.server.Server +import org.comixedproject.variant.shared.platform.Logger +import kotlin.native.concurrent.ThreadLocal + +private const val TAG = "FeedAPI" + +@ThreadLocal +public object FeedAPI { + private val nonStrictJson = Json { isLenient = true; ignoreUnknownKeys = true } + + public suspend fun loadDirectoryOnServer(server: Server, directory: String): HttpResponse { + val url = when (directory) { + "" -> server.url + else -> URL.resolve(server.url, directory) + } + val client = HttpClient { + install(ContentNegotiation) { + json(nonStrictJson) + } + + install(Logging) { + logger = HttpClientLogger + level = LogLevel.HEADERS + } + + install(Auth) { + basic { + credentials { + BasicAuthCredentials(username = server.username, password = server.password) + } + } + } + } + + Logger.d(TAG, "Loading directory on server: url=${url.toString()}") + return client.get(url.toString()) { + header(X_APP_NAME, APP_NAME) + header("accept", "application/xml") + }.body() + } +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/comixedproject/variant/shared/data/HttpClientLogger.kt b/shared/src/commonMain/kotlin/org/comixedproject/variant/shared/data/HttpClientLogger.kt new file mode 100644 index 0000000..5a65378 --- /dev/null +++ b/shared/src/commonMain/kotlin/org/comixedproject/variant/shared/data/HttpClientLogger.kt @@ -0,0 +1,29 @@ +/* + * Variant - A digital comic book reading application for the iPad and Android tablets. + * Copyright (C) 2024, The ComiXed Project + * + * 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 + */ + +package org.comixedproject.variant.shared.data + +import org.comixedproject.variant.shared.platform.Logger + +private const val TAG = "HttpClientLogger" + +public object HttpClientLogger : io.ktor.client.plugins.logging.Logger { + override fun log(message: String) { + Logger.d(TAG, message) + } +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/comixedproject/variant/shared/data/LinkRepository.kt b/shared/src/commonMain/kotlin/org/comixedproject/variant/shared/data/LinkRepository.kt new file mode 100644 index 0000000..9124723 --- /dev/null +++ b/shared/src/commonMain/kotlin/org/comixedproject/variant/shared/data/LinkRepository.kt @@ -0,0 +1,43 @@ +/* + * Variant - A digital comic book reading application for the iPad and Android tablets. + * Copyright (C) 2024, The ComiXed Project + * + * 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 + */ + +package org.comixedproject.variant.shared.data + +import org.comixedproject.variant.db.LinksDb +import org.comixedproject.variant.shared.model.server.Link + +class LinkRepository(private val databaseHelper: DatabaseHelper) { + fun loadAllLinks() = databaseHelper.loadAllLinks().map(LinksDb::map) + + fun linksForParent(serverId: String, directory: String) = + databaseHelper.loadLinks(serverId, directory).map(LinksDb::map) + + fun saveLinksForServer(serverId: String, directory: String, links: List) { + databaseHelper.saveLinksForServer(serverId, directory, links); + } +} + +fun LinksDb.map() = Link( + id = this.id, + serverId = this.serverId, + linkId = this.linkId, + directory = this.directory, + link = this.link, + title = this.title, + thumbnailURL = this.thumbnailURL +) diff --git a/shared/src/commonMain/kotlin/org/comixedproject/variant/shared/data/OpdsFeedData.kt b/shared/src/commonMain/kotlin/org/comixedproject/variant/shared/data/OpdsFeedData.kt new file mode 100644 index 0000000..1e3483a --- /dev/null +++ b/shared/src/commonMain/kotlin/org/comixedproject/variant/shared/data/OpdsFeedData.kt @@ -0,0 +1,50 @@ +/* + * Variant - A digital comic book reading application for the iPad and Android tablets. + * Copyright (C) 2024, The ComiXed Project + * + * 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 + */ + +package org.comixedproject.variant.shared.data + +import kotlinx.coroutines.coroutineScope +import org.comixedproject.variant.shared.model.server.Server +import org.comixedproject.variant.shared.platform.Logger + +private const val TAG = "OpdsFeedData" + +public class OpdsFeedData { + public suspend fun getDirectoryContent( + server: Server, + directory: String, + onSuccess: (Any) -> Unit, + onFailure: (Exception) -> Unit + ) { + try { + Logger.d(TAG, "Loading directory content: server=${server.name} directory=$directory") + val result = FeedAPI.loadDirectoryOnServer(server, directory) + Logger.d(TAG, "Parsing OPDS content") + coroutineScope { + onSuccess(result) + } + } catch (error: Exception) { + Logger.e(TAG, "Failed to load directory contents: $error") + coroutineScope { + { + onFailure(error); + } + } + } + } +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/comixedproject/variant/shared/domain/GetFeedData.kt b/shared/src/commonMain/kotlin/org/comixedproject/variant/shared/domain/GetFeedData.kt new file mode 100644 index 0000000..5a55659 --- /dev/null +++ b/shared/src/commonMain/kotlin/org/comixedproject/variant/shared/domain/GetFeedData.kt @@ -0,0 +1,79 @@ +/* + * Variant - A digital comic book reading application for the iPad and Android tablets. + * Copyright (C) 2024, The ComiXed Project + * + * 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 + */ + +package org.comixedproject.variant.shared.domain + +import io.ktor.client.statement.bodyAsText +import korlibs.io.serialization.xml.Xml +import kotlinx.coroutines.coroutineScope +import org.comixedproject.variant.shared.data.FeedAPI +import org.comixedproject.variant.shared.model.server.Link +import org.comixedproject.variant.shared.model.server.Server +import org.comixedproject.variant.shared.platform.Logger + +private const val TAG = "GetFeedData" + +public class GetFeedData { + public suspend fun invokeLoadDirectoryOnServer( + server: Server, + directory: String, + onSuccess: (List) -> Unit, + onFailure: (Exception) -> Unit + ) { + try { + Logger.d(TAG, "invokeLoadDirectoryOnServer: server=${server.name} directory=$directory") + val result = FeedAPI.loadDirectoryOnServer(server, directory) + Logger.d(TAG, "Result received: $result") + val xml = Xml.parse(result.bodyAsText()) + val feed = mutableListOf() + for (node in xml.allNodeChildren) { + val link = parseLink(node, server, directory) + + if (link != null) { + feed += link + } + } + coroutineScope { onSuccess(feed) } + } catch (error: Exception) { + Logger.e(TAG, "Failed to load directory: $error") + coroutineScope { onFailure(error) } + } + } + + fun parseLink(node: Xml, server: Server, directory: String): Link? { + if (node.name == "entry") { + val id = node.allNodeChildren.firstOrNull { it.name == "id" } + val title = node.allNodeChildren.firstOrNull { it.name == "title" } + val link = node.allNodeChildren.firstOrNull { + it.name == "link" + } + + return Link( + id = null, + serverId = server.id!!, + linkId = id?.text ?: "", + directory = directory, + link = link?.attributes?.get("href") ?: "", + title = title?.text ?: "", + thumbnailURL = null + ) + } else { + return null + } + } +} diff --git a/shared/src/commonMain/kotlin/org/comixedproject/variant/shared/domain/LinkFeedData.kt b/shared/src/commonMain/kotlin/org/comixedproject/variant/shared/domain/LinkFeedData.kt new file mode 100644 index 0000000..465dd3e --- /dev/null +++ b/shared/src/commonMain/kotlin/org/comixedproject/variant/shared/domain/LinkFeedData.kt @@ -0,0 +1,31 @@ +/* + * Variant - A digital comic book reading application for the iPad and Android tablets. + * Copyright (C) 2024, The ComiXed Project + * + * 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 + */ + +package org.comixedproject.variant.shared.domain + +import org.comixedproject.variant.shared.model.server.Link +import org.comixedproject.variant.shared.model.server.Server + +public interface LinkFeedData { + public fun onNewLinksReceived( + server: Server, + directory: String, + links: List, + exception: Exception? + ) +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/comixedproject/variant/shared/domain/ServiceLocator.kt b/shared/src/commonMain/kotlin/org/comixedproject/variant/shared/domain/ServiceLocator.kt new file mode 100644 index 0000000..471a791 --- /dev/null +++ b/shared/src/commonMain/kotlin/org/comixedproject/variant/shared/domain/ServiceLocator.kt @@ -0,0 +1,26 @@ +/* + * Variant - A digital comic book reading application for the iPad and Android tablets. + * Copyright (C) 2024, The ComiXed Project + * + * 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 + */ + +package org.comixedproject.variant.shared.domain + +import org.comixedproject.variant.shared.presentation.FeedPresenter + +public object ServiceLocator { + public val getFeed: GetFeedData = GetFeedData() + public val getFeedPresenter: FeedPresenter = FeedPresenter(getFeed) +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/comixedproject/variant/shared/model/VariantViewModel.kt b/shared/src/commonMain/kotlin/org/comixedproject/variant/shared/model/VariantViewModel.kt index 8615ce4..b476a61 100644 --- a/shared/src/commonMain/kotlin/org/comixedproject/variant/shared/model/VariantViewModel.kt +++ b/shared/src/commonMain/kotlin/org/comixedproject/variant/shared/model/VariantViewModel.kt @@ -18,25 +18,82 @@ package org.comixedproject.variant.shared.model +import org.comixedproject.variant.shared.data.LinkRepository import org.comixedproject.variant.shared.data.ServerRepository +import org.comixedproject.variant.shared.domain.LinkFeedData +import org.comixedproject.variant.shared.domain.ServiceLocator +import org.comixedproject.variant.shared.model.server.Link import org.comixedproject.variant.shared.model.server.Server +import org.comixedproject.variant.shared.platform.Logger + +private const val TAG = "VariantViewModel" + +class VariantViewModel( + val serverRepository: ServerRepository, + val linkRepository: LinkRepository +) : BaseViewModel(), LinkFeedData { + + private var _serverId: String? = null + + private var _directory: String? = null + + private var _loading = false -class VariantViewModel(val serverRepository: ServerRepository) : BaseViewModel() { val servers: List get() = serverRepository.servers + val allLinks: List = linkRepository.loadAllLinks() + + val links: List + get() = linkRepository.loadAllLinks() + .filter { link -> _serverId != null && link.serverId == _serverId!! } // linksForParent(_server?.id, _directory?.orEmpty()) + .filter { link -> _directory != null && link.directory == _directory!! }.toList() + var onServerUpdate: ((List) -> Unit)? = null set(value) { field = value onServerUpdate?.invoke(servers) } + private val presenter by lazy { ServiceLocator.getFeedPresenter } + fun saveServer(server: Server) { serverRepository.saveServer(server) onServerUpdate?.invoke(servers) } + fun loadServerFeed(server: Server, directory: String) { + if (_loading == false) { + Logger.d(TAG, "Loading server feed: server=${server.name} directory=$directory") + presenter.loadDirectoryOnServer(server, directory, this) + } else { + Logger.d( + TAG, + "Currently loading feed: ignoring request for server=${server.name} directory=$directory" + ) + } + } + + fun linksFor(server: Server, parentHref: String) = + linkRepository.linksForParent(server.id!!, parentHref) + fun deleteServer(server: Server) { serverRepository.deleteServer(server) } + + override fun onNewLinksReceived( + server: Server, + directory: String, + links: List, + exception: Exception? + ) { + if (exception != null) { + Logger.e("Error receiving links", exception.toString()) + } else { + Logger.d(TAG, "Saving ${links.size} link(s) from ${server.name} in ${directory}") + linkRepository.saveLinksForServer(server.id!!, directory, links) + _serverId = server.id + _directory = directory + } + } } \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/comixedproject/variant/shared/model/server/Link.kt b/shared/src/commonMain/kotlin/org/comixedproject/variant/shared/model/server/Link.kt new file mode 100644 index 0000000..f90e535 --- /dev/null +++ b/shared/src/commonMain/kotlin/org/comixedproject/variant/shared/model/server/Link.kt @@ -0,0 +1,12 @@ +package org.comixedproject.variant.shared.model.server + +class Link( + val id: String?, + val serverId: String, + val linkId: String, + val directory: String, + val link: String, + val title: String, + val thumbnailURL: String? +) { +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/comixedproject/variant/shared/platform/Logger.kt b/shared/src/commonMain/kotlin/org/comixedproject/variant/shared/platform/Logger.kt new file mode 100644 index 0000000..a0f850d --- /dev/null +++ b/shared/src/commonMain/kotlin/org/comixedproject/variant/shared/platform/Logger.kt @@ -0,0 +1,41 @@ +/* + * Variant - A digital comic book reading application for the iPad and Android tablets. + * Copyright (C) 2024, The ComiXed Project + * + * 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 + */ + +package org.comixedproject.variant.shared.platform + +internal expect class Log() { + fun debug(tag: String, message: String) + fun warn(tag: String, message: String) + fun error(tag: String, message: String) +} + +public object Logger { + private val logger = Log() + + public fun d(tag: String, message: String) { + logger.debug(tag, message) + } + + public fun w(tag: String, message: String) { + logger.warn(tag, message) + } + + public fun e(tag: String, message: String) { + logger.error(tag, message) + } +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/comixedproject/variant/shared/presentation/FeedPresenter.kt b/shared/src/commonMain/kotlin/org/comixedproject/variant/shared/presentation/FeedPresenter.kt new file mode 100644 index 0000000..1a0d577 --- /dev/null +++ b/shared/src/commonMain/kotlin/org/comixedproject/variant/shared/presentation/FeedPresenter.kt @@ -0,0 +1,43 @@ +/* + * Variant - A digital comic book reading application for the iPad and Android tablets. + * Copyright (C) 2024, The ComiXed Project + * + * 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 + */ + +package org.comixedproject.variant.shared.presentation + +import kotlinx.coroutines.MainScope +import kotlinx.coroutines.launch +import org.comixedproject.variant.shared.domain.GetFeedData +import org.comixedproject.variant.shared.domain.LinkFeedData +import org.comixedproject.variant.shared.model.server.Server + +private const val TAG = "FeedPresenter" + +class FeedPresenter(private val feedData: GetFeedData) { + public fun loadDirectoryOnServer( + server: Server, + directory: String, + feed: LinkFeedData + ) { + MainScope().launch { + feedData.invokeLoadDirectoryOnServer(server, directory, onSuccess = { links -> + feed.onNewLinksReceived(server, directory, links, null) + }, onFailure = { error -> + feed.onNewLinksReceived(server, directory, emptyList(), error) + }) + } + } +} \ No newline at end of file diff --git a/shared/src/commonMain/sqldelight/org/comixedproject/variant/db/Table.sq b/shared/src/commonMain/sqldelight/org/comixedproject/variant/db/Table.sq index fd93b1c..a7fc829 100644 --- a/shared/src/commonMain/sqldelight/org/comixedproject/variant/db/Table.sq +++ b/shared/src/commonMain/sqldelight/org/comixedproject/variant/db/Table.sq @@ -16,4 +16,29 @@ updateServer: UPDATE ServersDb SET name = ?, url = ?, username = ?, password = ? WHERE id = ?; deleteServer: -DELETE FROM ServersDb WHERE id = ?; \ No newline at end of file +DELETE FROM ServersDb WHERE id = ?; + +CREATE TABLE LinksDb ( +id TEXT NOT NULL PRIMARY KEY, +serverId TEXT NOT NULL, +linkId TEXT NOT NULL, +directory TEXT NOT NULL, +link TEXT NOT NULL, +title TEXT NOT NULL, +thumbnailURL TEXT +); + +createLink: +INSERT OR IGNORE INTO LinksDb(id, serverId, linkId, directory, link, title, thumbnailURL) VALUES(?, ?, ?,?, ?, ?, ?); + +getExistingLink: +SELECT id FROM LinksDb WHERE serverId = ? AND directory = ?; + +deleteExistingLink: +DELETE FROM LinksDb WHERE id = ?; + +loadAllLinks: +SELECT * FROM LinksDb; + +loadLinksForParent: +SELECT * FROM LinksDb WHERE serverId = ? AND directory = ?; \ No newline at end of file diff --git a/shared/src/iosMain/kotlin/org/comixedproject/variant/shared/KoinIOS.kt b/shared/src/iosMain/kotlin/org/comixedproject/variant/shared/KoinIOS.kt index ed32b9d..def5199 100644 --- a/shared/src/iosMain/kotlin/org/comixedproject/variant/shared/KoinIOS.kt +++ b/shared/src/iosMain/kotlin/org/comixedproject/variant/shared/KoinIOS.kt @@ -29,7 +29,6 @@ import org.koin.core.module.Module import org.koin.core.parameter.parametersOf import org.koin.core.qualifier.Qualifier import org.koin.dsl.module -import platform.Foundation.NSUserDefaults actual val platformModule: Module = module { single { @@ -38,11 +37,7 @@ actual val platformModule: Module = module { } object KoinIOS { - fun initialize( - userDefaults: NSUserDefaults, - ): KoinApplication = initKoin( - appModule = module {} - ) + fun initialize(): KoinApplication = initKoin() } @kotlinx.cinterop.BetaInteropApi diff --git a/shared/src/iosMain/kotlin/org/comixedproject/variant/shared/platform/Logger.ios.kt b/shared/src/iosMain/kotlin/org/comixedproject/variant/shared/platform/Logger.ios.kt new file mode 100644 index 0000000..fb0e9e9 --- /dev/null +++ b/shared/src/iosMain/kotlin/org/comixedproject/variant/shared/platform/Logger.ios.kt @@ -0,0 +1,35 @@ +/* + * Variant - A digital comic book reading application for the iPad and Android tablets. + * Copyright (C) 2024, The ComiXed Project + * + * 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 + */ + +package org.comixedproject.variant.shared.platform + +import platform.Foundation.NSLog + +internal actual class Log { + actual fun debug(tag: String, message: String) { + NSLog("$tag | $message") + } + + actual fun warn(tag: String, message: String) { + NSLog("$tag | $message") + } + + actual fun error(tag: String, message: String) { + NSLog("$tag | $message") + } +} \ No newline at end of file