diff --git a/app/src/main/java/de/tum/informatics/www1/artemis/native_app/android/ui/MainActivity.kt b/app/src/main/java/de/tum/informatics/www1/artemis/native_app/android/ui/MainActivity.kt
index e2d84904e..51088a045 100644
--- a/app/src/main/java/de/tum/informatics/www1/artemis/native_app/android/ui/MainActivity.kt
+++ b/app/src/main/java/de/tum/informatics/www1/artemis/native_app/android/ui/MainActivity.kt
@@ -37,6 +37,7 @@ import de.tum.informatics.www1.artemis.native_app.core.ui.LocalLinkOpener
import de.tum.informatics.www1.artemis.native_app.core.ui.LocalWindowSizeClassProvider
import de.tum.informatics.www1.artemis.native_app.core.ui.WindowSizeClassProvider
import de.tum.informatics.www1.artemis.native_app.core.ui.alert.TextAlertDialog
+import de.tum.informatics.www1.artemis.native_app.core.ui.markdown.link_resolving.LocalMarkdownLinkResolver
import de.tum.informatics.www1.artemis.native_app.core.ui.remote_images.LocalArtemisImageProvider
import de.tum.informatics.www1.artemis.native_app.feature.courseregistration.courseRegistration
import de.tum.informatics.www1.artemis.native_app.feature.courseregistration.navigateToCourseRegistration
@@ -255,7 +256,8 @@ class MainActivity : AppCompatActivity(),
CompositionLocalProvider(
LocalWindowSizeClassProvider provides windowSizeClassProvider,
LocalLinkOpener provides linkOpener,
- LocalArtemisImageProvider provides koinInject()
+ LocalArtemisImageProvider provides koinInject(),
+ LocalMarkdownLinkResolver provides koinInject()
) {
// Use jetpack compose navigation for the navigation logic.
NavHost(navController = navController, startDestination = startDestination) {
diff --git a/core/ui/build.gradle.kts b/core/ui/build.gradle.kts
index 6e3c5708a..209a76bfc 100644
--- a/core/ui/build.gradle.kts
+++ b/core/ui/build.gradle.kts
@@ -32,11 +32,12 @@ dependencies {
debugApi(libs.coil.compose.core)
debugApi(libs.coil.test)
api(libs.accompanist.webview)
+ api(libs.noties.markwon.core)
implementation(libs.kotlinx.datetime)
implementation(libs.kotlinx.coroutines.android)
+ implementation(libs.androidx.browser)
- implementation(libs.noties.markwon.core)
implementation(libs.noties.markwon.ext.strikethrough)
implementation(libs.noties.markwon.ext.tables)
implementation(libs.noties.markwon.html)
diff --git a/core/ui/src/main/AndroidManifest.xml b/core/ui/src/main/AndroidManifest.xml
new file mode 100644
index 000000000..0fdec3e1a
--- /dev/null
+++ b/core/ui/src/main/AndroidManifest.xml
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/core/ui/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/ui/compose/ArtemisPdfView.kt b/core/ui/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/ui/compose/ArtemisPdfView.kt
new file mode 100644
index 000000000..75f63f356
--- /dev/null
+++ b/core/ui/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/ui/compose/ArtemisPdfView.kt
@@ -0,0 +1,251 @@
+package de.tum.informatics.www1.artemis.native_app.core.ui.compose
+
+import android.widget.Toast
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Download
+import androidx.compose.material.icons.filled.MoreHoriz
+import androidx.compose.material.icons.filled.Share
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.DropdownMenu
+import androidx.compose.material3.DropdownMenuItem
+import androidx.compose.material3.FloatingActionButton
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import de.tum.informatics.www1.artemis.native_app.core.ui.R
+import de.tum.informatics.www1.artemis.native_app.core.ui.pdf.PdfFile
+import de.tum.informatics.www1.artemis.native_app.core.ui.pdf.render.HorizontalPdfView
+import de.tum.informatics.www1.artemis.native_app.core.ui.pdf.render.VerticalPdfView
+import de.tum.informatics.www1.artemis.native_app.core.ui.pdf.render.state.HorizontalPdfReaderState
+import de.tum.informatics.www1.artemis.native_app.core.ui.pdf.render.state.PdfReaderState
+import de.tum.informatics.www1.artemis.native_app.core.ui.pdf.render.state.VerticalPdfReaderState
+import de.tum.informatics.www1.artemis.native_app.core.ui.pdf.render.state.rememberHorizontalPdfReaderState
+import de.tum.informatics.www1.artemis.native_app.core.ui.pdf.render.state.rememberVerticalPdfReaderState
+import java.net.UnknownHostException
+
+@Composable
+fun ArtemisPdfView(
+ modifier: Modifier,
+ pdfFile: PdfFile,
+ dismiss: () -> Unit
+) {
+ val isVertical = remember { mutableStateOf(true) }
+ val context = LocalContext.current
+
+ val verticalPdfState = rememberVerticalPdfReaderState(
+ pdfFile = pdfFile,
+ isZoomEnabled = true,
+ )
+ val horizontalPdfState = rememberHorizontalPdfReaderState(
+ pdfFile = pdfFile,
+ isZoomEnabled = true,
+ )
+
+ var pdfState = if (isVertical.value) verticalPdfState else horizontalPdfState
+
+ val showMenu = remember { mutableStateOf(false) }
+
+ if (pdfState.mError != null) {
+ val errorMessage = when (pdfState.mError) {
+ is UnknownHostException -> {
+ R.string.pdf_view_error_no_internet
+ }
+ else -> {
+ R.string.pdf_view_error_loading
+ }
+ }
+
+ Toast.makeText(
+ LocalContext.current,
+ stringResource(id = errorMessage),
+ Toast.LENGTH_LONG
+ ).show()
+
+ dismiss()
+ }
+
+ Box(
+ modifier = modifier.padding(8.dp),
+ ) {
+ Column {
+ pdfState.file?.let {
+ Text(
+ text = pdfFile.filename ?: it.name,
+ modifier = Modifier.padding(8.dp),
+ style = MaterialTheme.typography.titleMedium
+ )
+ }
+ if (isVertical.value) {
+ VerticalPdfView(
+ state = pdfState as VerticalPdfReaderState,
+ modifier = Modifier
+ .background(color = MaterialTheme.colorScheme.surface)
+ .fillMaxSize()
+ )
+ } else {
+ HorizontalPdfView(
+ state = pdfState as HorizontalPdfReaderState,
+ modifier = Modifier
+ .fillMaxSize()
+ .background(
+ color = MaterialTheme.colorScheme.surface,
+ shape = MaterialTheme.shapes.medium
+ )
+ )
+ }
+ }
+
+ Column(
+ modifier = Modifier
+ .align(Alignment.BottomEnd)
+ .padding(16.dp)
+ ) {
+ if (showMenu.value) {
+ Spacer(modifier = Modifier.height(16.dp))
+ }
+
+ FloatingActionButton(
+ onClick = { showMenu.value = !showMenu.value },
+ modifier = Modifier
+ ) {
+ Icon(imageVector = Icons.Default.MoreHoriz, contentDescription = null)
+ }
+
+ PdfActionDropDownMenu(
+ modifier = Modifier,
+ isExpanded = showMenu.value,
+ onDismissRequest = { showMenu.value = false },
+ downloadPdf = {
+ showMenu.value = false
+ pdfState.file?.let { pdfFile.downloadPdf(context) }
+ },
+ sharePdf = {
+ showMenu.value = false
+ pdfState.file?.let { pdfFile.sharePdf(context, it) }
+ },
+ toggleViewMode = {
+ showMenu.value = false
+ pdfState = if (isVertical.value) horizontalPdfState else verticalPdfState
+ isVertical.value = !isVertical.value
+ }
+ )
+ }
+
+ if (!pdfState.isLoaded) {
+ Box(
+ modifier = Modifier
+ .fillMaxSize(),
+ contentAlignment = Alignment.Center
+ ) {
+ CircularProgressIndicator(
+ progress = { pdfState.loadPercent / 100f },
+ modifier = Modifier.height(48.dp),
+ color = MaterialTheme.colorScheme.primary
+ )
+ }
+ }
+
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .align(Alignment.TopStart)
+ .padding(top = 54.dp, end = 4.dp)
+ .padding(16.dp),
+ contentAlignment = Alignment.TopEnd
+ ) {
+ PageCountText(
+ modifier = Modifier,
+ pdfState = pdfState
+ )
+ }
+ }
+}
+
+@Composable
+private fun PageCountText(
+ modifier: Modifier,
+ pdfState: PdfReaderState
+) {
+ Box(
+ modifier = modifier
+ .background(
+ color = MaterialTheme.colorScheme.surface.copy(alpha = 0.5f),
+ shape = MaterialTheme.shapes.medium
+ )
+ .padding(8.dp)
+ ) {
+ Text(
+ text = stringResource(
+ R.string.pdf_view_page_count,
+ pdfState.currentPage,
+ pdfState.pdfPageCount
+ ),
+ style = MaterialTheme.typography.bodyMedium
+ )
+ }
+}
+
+@Composable
+private fun PdfActionDropDownMenu(
+ modifier: Modifier,
+ isExpanded: Boolean,
+ onDismissRequest: () -> Unit,
+ downloadPdf: () -> Unit,
+ sharePdf: () -> Unit,
+ toggleViewMode: () -> Unit,
+) {
+ DropdownMenu(
+ modifier = Modifier,
+ expanded = isExpanded,
+ onDismissRequest = onDismissRequest
+ ) {
+ DropdownMenuItem(
+ leadingIcon = {
+ Icon(
+ imageVector = Icons.Default.Download,
+ contentDescription = null
+ )
+ },
+ text = { Text(stringResource(R.string.pdf_view_download_menu_item)) },
+ onClick = downloadPdf
+ )
+ DropdownMenuItem(
+ leadingIcon = {
+ Icon(
+ imageVector = Icons.Default.Share,
+ contentDescription = null
+ )
+ },
+ text = { Text(stringResource(R.string.pdf_view_share_menu_item)) },
+ onClick = sharePdf
+ )
+ DropdownMenuItem(
+ leadingIcon = {
+ Icon(
+ modifier = Modifier.height(19.dp),
+ painter = painterResource(id = R.drawable.rotate),
+ contentDescription = null
+ )
+ },
+ text = { Text(stringResource(R.string.pdf_view_rotate_menu_item)) },
+ onClick = toggleViewMode
+ )
+ }
+}
\ No newline at end of file
diff --git a/feature/exercise-view/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/exerciseview/ArtemisWebView.kt b/core/ui/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/ui/compose/ArtemisWebView.kt
similarity index 58%
rename from feature/exercise-view/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/exerciseview/ArtemisWebView.kt
rename to core/ui/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/ui/compose/ArtemisWebView.kt
index 7da78614e..e7e73ee63 100644
--- a/feature/exercise-view/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/exerciseview/ArtemisWebView.kt
+++ b/core/ui/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/ui/compose/ArtemisWebView.kt
@@ -1,4 +1,4 @@
-package de.tum.informatics.www1.artemis.native_app.feature.exerciseview
+package de.tum.informatics.www1.artemis.native_app.core.ui.compose
import android.annotation.SuppressLint
import android.content.Context
@@ -14,93 +14,16 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
-import androidx.compose.runtime.derivedStateOf
-import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import com.google.accompanist.web.AccompanistWebViewClient
-import com.google.accompanist.web.WebContent
import com.google.accompanist.web.WebView
import com.google.accompanist.web.WebViewState
-import de.tum.informatics.www1.artemis.native_app.core.ui.exercise.ResultTemplateStatus
-import io.ktor.http.*
-
-@Composable
-internal fun getProblemStatementWebViewState(
- serverUrl: String,
- courseId: Long?,
- exerciseId: Long?,
- participationId: Long?
-): WebViewState? {
- val url by remember(serverUrl, courseId, exerciseId) {
- derivedStateOf {
- if (courseId != null && exerciseId != null) {
- URLBuilder(serverUrl).apply {
- appendPathSegments(
- "courses",
- courseId.toString(),
- "exercises",
- exerciseId.toString(),
- "problem-statement"
- )
-
- if (participationId != null) {
- appendPathSegments(participationId.toString())
- }
- }
- .buildString()
- } else null
- }
- }
-
- return remember(url) {
- derivedStateOf {
- url?.let {
- WebViewState(WebContent.Url(url = it))
- }
- }
- }.value
-}
-
-@Composable
-internal fun getFeedbackViewWebViewState(
- serverUrl: String,
- courseId: Long,
- exerciseId: Long,
- participationId: Long,
- resultId: Long,
- templateStatus: ResultTemplateStatus
-): WebViewState {
- val url by remember(serverUrl, courseId, exerciseId, resultId, templateStatus) {
- derivedStateOf {
- URLBuilder(serverUrl).apply {
- appendPathSegments(
- "courses",
- courseId.toString(),
- "exercises",
- exerciseId.toString(),
- "feedback",
- participationId.toString(),
- resultId.toString(),
- (templateStatus == ResultTemplateStatus.Missing).toString()
- )
- }.buildString()
- }
- }
-
- return remember(url) {
- derivedStateOf {
- url.let {
- WebViewState(WebContent.Url(url = it))
- }
- }
- }.value
-}
@SuppressLint("SetJavaScriptEnabled")
@Composable
-internal fun ArtemisWebView(
+fun ArtemisWebView(
modifier: Modifier,
webViewState: WebViewState,
webView: WebView?,
@@ -183,4 +106,6 @@ private class ThemeClient(
super.onPageFinished(view, url)
}
-}
\ No newline at end of file
+}
+
+
diff --git a/core/ui/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/ui/compose/LinkBottomSheet.kt b/core/ui/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/ui/compose/LinkBottomSheet.kt
new file mode 100644
index 000000000..4e00a3494
--- /dev/null
+++ b/core/ui/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/ui/compose/LinkBottomSheet.kt
@@ -0,0 +1,102 @@
+package de.tum.informatics.www1.artemis.native_app.core.ui.compose
+
+import android.annotation.SuppressLint
+import android.webkit.WebView
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.WindowInsets
+import androidx.compose.foundation.layout.asPaddingValues
+import androidx.compose.foundation.layout.fillMaxHeight
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.systemBars
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.rememberLazyListState
+import androidx.compose.material3.ModalBottomSheet
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.derivedStateOf
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+import com.google.accompanist.web.WebContent
+import com.google.accompanist.web.WebViewState
+import de.tum.informatics.www1.artemis.native_app.core.ui.pdf.PdfFile
+
+enum class LinkBottomSheetState {
+ PDFVIEWSTATE,
+ WEBVIEWSTATE
+}
+
+@SuppressLint("SetJavaScriptEnabled")
+@Composable
+fun LinkBottomSheet(
+ modifier: Modifier,
+ serverUrl: String,
+ authToken: String,
+ link: String,
+ fileName: String?,
+ state: LinkBottomSheetState,
+ onDismissRequest: () -> Unit
+) {
+ var webView: WebView? by remember { mutableStateOf(null) }
+ val webViewState = getWebViewState(link)
+
+ ModalBottomSheet(
+ modifier = modifier.padding(
+ top = WindowInsets.systemBars.asPaddingValues().calculateTopPadding(),
+ ),
+ onDismissRequest = onDismissRequest
+ ) {
+ Box(
+ modifier = Modifier
+ .fillMaxHeight()
+ .padding(8.dp)
+ ) {
+ when (state) {
+ LinkBottomSheetState.PDFVIEWSTATE -> {
+ val pdfFile = PdfFile(link, authToken, fileName)
+ ArtemisPdfView(
+ modifier = Modifier.fillMaxSize(),
+ pdfFile = pdfFile,
+ dismiss = onDismissRequest
+ )
+ }
+
+ LinkBottomSheetState.WEBVIEWSTATE -> {
+ // The lazy column is needed to support scrolling
+ LazyColumn(
+ modifier = Modifier
+ .fillMaxHeight(),
+ state = rememberLazyListState()
+ ) {
+ item {
+ ArtemisWebView(
+ modifier = Modifier
+ .fillMaxHeight()
+ .padding(8.dp),
+ webViewState = webViewState,
+ webView = webView,
+ setWebView = { webView = it },
+ serverUrl = serverUrl,
+ authToken = authToken
+ )
+ }
+ }
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun getWebViewState(
+ link: String
+): WebViewState {
+ return remember(link) {
+ derivedStateOf {
+ WebViewState(WebContent.Url(url = link))
+ }
+ }.value
+}
\ No newline at end of file
diff --git a/core/ui/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/ui/markdown/LocalMarkwon.kt b/core/ui/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/ui/markdown/LocalMarkwon.kt
index faddae433..782c4a884 100644
--- a/core/ui/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/ui/markdown/LocalMarkwon.kt
+++ b/core/ui/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/ui/markdown/LocalMarkwon.kt
@@ -6,6 +6,7 @@ import androidx.compose.runtime.ProvidableCompositionLocal
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalContext
+import de.tum.informatics.www1.artemis.native_app.core.ui.markdown.link_resolving.LocalMarkdownLinkResolver
import de.tum.informatics.www1.artemis.native_app.core.ui.remote_images.LocalArtemisImageProvider
import io.noties.markwon.Markwon
@@ -15,11 +16,12 @@ val LocalMarkwon: ProvidableCompositionLocal =
@Composable
fun ProvideMarkwon(content: @Composable () -> Unit) {
val imageLoader = LocalArtemisImageProvider.current.rememberArtemisImageLoader()
+ val linkResolver = LocalMarkdownLinkResolver.current.rememberMarkdownLinkResolver()
val context = LocalContext.current
val imageWidth = context.resources.displayMetrics.widthPixels
- val markdownRender: Markwon = remember(imageLoader) {
- createMarkdownRender(context, imageLoader, imageWidth)
+ val markdownRender: Markwon = remember(imageLoader, linkResolver) {
+ createMarkdownRender(context, imageLoader, linkResolver, imageWidth)
}
CompositionLocalProvider(LocalMarkwon provides markdownRender) {
diff --git a/core/ui/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/ui/markdown/MarkdownText.kt b/core/ui/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/ui/markdown/MarkdownText.kt
index 7dc0cb905..f63527f1d 100644
--- a/core/ui/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/ui/markdown/MarkdownText.kt
+++ b/core/ui/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/ui/markdown/MarkdownText.kt
@@ -38,7 +38,9 @@ import de.tum.informatics.www1.artemis.native_app.core.common.R
import de.tum.informatics.www1.artemis.native_app.core.common.markdown.ArtemisMarkdownTransformer
import de.tum.informatics.www1.artemis.native_app.core.common.markdown.TYPE_ICON_RESOURCE_PATH
import io.noties.markwon.AbstractMarkwonPlugin
+import io.noties.markwon.LinkResolver
import io.noties.markwon.Markwon
+import io.noties.markwon.MarkwonConfiguration
import io.noties.markwon.core.MarkwonTheme
import io.noties.markwon.ext.strikethrough.StrikethroughPlugin
import io.noties.markwon.ext.tables.TablePlugin
@@ -93,14 +95,15 @@ fun MarkdownText(
onClick: (() -> Unit)? = null,
onLongClick: (() -> Unit)? = null,
imageLoader: ImageLoader? = null,
+ linkResolver: LinkResolver? = null
) {
val defaultColor: Color = LocalContentColor.current
val context: Context = LocalContext.current
val localMarkwon = LocalMarkwon.current
val imageWidth = context.resources.displayMetrics.widthPixels
- val markdownRender: Markwon = localMarkwon ?: remember(imageLoader) {
- createMarkdownRender(context, imageLoader, imageWidth)
+ val markdownRender: Markwon = localMarkwon ?: remember(imageLoader, linkResolver) {
+ createMarkdownRender(context, imageLoader, linkResolver, imageWidth)
}
val markdownTransformer = LocalMarkdownTransformer.current
@@ -224,7 +227,7 @@ private fun TextView.applyStyleAndColor(
}
}
-fun createMarkdownRender(context: Context, imageLoader: ImageLoader?, imageWidth: Int): Markwon {
+fun createMarkdownRender(context: Context, imageLoader: ImageLoader?, linkResolver: LinkResolver?, imageWidth: Int): Markwon {
val imagePlugin: CoilImagesPlugin? =
if (imageLoader != null) {
CoilImagesPlugin.create(
@@ -268,6 +271,13 @@ fun createMarkdownRender(context: Context, imageLoader: ImageLoader?, imageWidth
if (imagePlugin != null) {
usePlugin(imagePlugin)
}
+ if (linkResolver != null) {
+ usePlugin(object : AbstractMarkwonPlugin() {
+ override fun configureConfiguration(builder: MarkwonConfiguration.Builder) {
+ builder.linkResolver(linkResolver)
+ }
+ })
+ }
}
.build()
}
\ No newline at end of file
diff --git a/core/ui/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/ui/markdown/link_resolving/MarkdownLinkResolver.kt b/core/ui/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/ui/markdown/link_resolving/MarkdownLinkResolver.kt
new file mode 100644
index 000000000..8d2dbcdcb
--- /dev/null
+++ b/core/ui/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/ui/markdown/link_resolving/MarkdownLinkResolver.kt
@@ -0,0 +1,12 @@
+package de.tum.informatics.www1.artemis.native_app.core.ui.markdown.link_resolving
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.compositionLocalOf
+import io.noties.markwon.LinkResolver
+
+val LocalMarkdownLinkResolver = compositionLocalOf { error("No MarkdownLinkResolver provided") }
+
+interface MarkdownLinkResolver {
+ @Composable
+ fun rememberMarkdownLinkResolver(): LinkResolver
+}
\ No newline at end of file
diff --git a/core/ui/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/ui/markdown/link_resolving/MarkdownLinkResolverImpl.kt b/core/ui/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/ui/markdown/link_resolving/MarkdownLinkResolverImpl.kt
new file mode 100644
index 000000000..1be2c74fc
--- /dev/null
+++ b/core/ui/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/ui/markdown/link_resolving/MarkdownLinkResolverImpl.kt
@@ -0,0 +1,79 @@
+package de.tum.informatics.www1.artemis.native_app.core.ui.markdown.link_resolving
+
+import android.content.Context
+import android.view.View
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import de.tum.informatics.www1.artemis.native_app.core.datastore.AccountService
+import de.tum.informatics.www1.artemis.native_app.core.datastore.ServerConfigurationService
+import de.tum.informatics.www1.artemis.native_app.core.datastore.authToken
+import de.tum.informatics.www1.artemis.native_app.core.ui.LinkOpener
+import de.tum.informatics.www1.artemis.native_app.core.ui.LocalLinkOpener
+import de.tum.informatics.www1.artemis.native_app.core.ui.compose.LinkBottomSheet
+import de.tum.informatics.www1.artemis.native_app.core.ui.compose.LinkBottomSheetState
+import io.noties.markwon.LinkResolver
+
+class MarkdownLinkResolverImpl(
+ private val accountService: AccountService,
+ private val serverConfigurationService: ServerConfigurationService,
+): MarkdownLinkResolver {
+ @Composable
+ override fun rememberMarkdownLinkResolver(): LinkResolver {
+ val serverUrl by serverConfigurationService.serverUrl.collectAsState(initial = "")
+ val authToken by accountService.authToken.collectAsState(initial = "")
+ val context = LocalContext.current
+ val localLinkOpener = LocalLinkOpener.current
+
+ val (bottomSheetLink, setLinkToShow) = remember { mutableStateOf(null) }
+ val (bottomSheetState, setBottomSheetState) = remember { mutableStateOf(LinkBottomSheetState.WEBVIEWSTATE) }
+
+ if (bottomSheetLink != null) {
+ val filename = if (bottomSheetState == LinkBottomSheetState.PDFVIEWSTATE) bottomSheetLink.substringAfterLast("/") else null
+ LinkBottomSheet(
+ modifier = Modifier.fillMaxSize(),
+ serverUrl = serverUrl,
+ authToken = authToken,
+ link = bottomSheetLink,
+ fileName = filename,
+ state = bottomSheetState,
+ onDismissRequest = { setLinkToShow(null) }
+ )
+ }
+
+ return remember(context, localLinkOpener, authToken, serverUrl) {
+ BaseMarkdownLinkResolver(context, localLinkOpener, serverUrl, setLinkToShow, setBottomSheetState)
+ }
+ }
+}
+
+class BaseMarkdownLinkResolver(
+ private val context: Context,
+ private val linkOpener: LinkOpener,
+ private val serverUrl: String = "",
+ private val showModalBottomSheet: (String) -> Unit,
+ private val setBottomSheetState: (LinkBottomSheetState) -> Unit
+) : LinkResolver {
+ override fun resolve(view: View, link: String) {
+ when {
+ link.endsWith(".pdf") -> {
+ setBottomSheetState(LinkBottomSheetState.PDFVIEWSTATE)
+ showModalBottomSheet(link)
+ }
+ // TODO: open Artemis link in a Modal Bottom Sheet webview to attach session cookie (https://github.com/ls1intum/artemis-android/issues/245)
+// link.startsWith(serverUrl) -> {
+// setBottomSheetState(LinkBottomSheetState.WEBVIEWSTATE)
+// showModalBottomSheet(link)
+// }
+ else -> {
+ linkOpener.openLink(link)
+ }
+ }
+ }
+}
+
diff --git a/core/ui/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/ui/pdf/Notice.md b/core/ui/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/ui/pdf/Notice.md
new file mode 100644
index 000000000..912e972f6
--- /dev/null
+++ b/core/ui/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/ui/pdf/Notice.md
@@ -0,0 +1,19 @@
+### Disclaimer
+
+The files included in this package have been heavily influenced by:
+
+https://github.com/GRizzi91/bouquet
+
+Copyright [2022] [Graziano Rizzi]
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
\ No newline at end of file
diff --git a/core/ui/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/ui/pdf/PdfFile.kt b/core/ui/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/ui/pdf/PdfFile.kt
new file mode 100644
index 000000000..659b4640d
--- /dev/null
+++ b/core/ui/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/ui/pdf/PdfFile.kt
@@ -0,0 +1,201 @@
+package de.tum.informatics.www1.artemis.native_app.core.ui.pdf
+
+import android.app.DownloadManager
+import android.content.ClipData
+import android.content.Context
+import android.content.Intent
+import android.net.Uri
+import android.os.Environment
+import android.os.ParcelFileDescriptor
+import android.util.Log
+import android.widget.Toast
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.ImageBitmap
+import androidx.compose.ui.layout.ContentScale
+import androidx.core.content.ContextCompat.getString
+import androidx.core.content.FileProvider
+import de.tum.informatics.www1.artemis.native_app.core.ui.R
+import de.tum.informatics.www1.artemis.native_app.core.ui.pdf.render.PdfRendering
+import de.tum.informatics.www1.artemis.native_app.core.ui.pdf.render.state.PdfReaderState
+import io.ktor.http.HttpHeaders
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+import okhttp3.OkHttpClient
+import okhttp3.Request
+import java.io.File
+import java.net.UnknownHostException
+import java.util.Date
+
+
+class PdfFile(
+ val link: String,
+ val authToken: String,
+ val filename: String? = null
+) {
+
+ private fun generateFileName(): String = "${Date().time}.pdf"
+
+ fun load(
+ coroutineScope: CoroutineScope,
+ context: Context,
+ state: PdfReaderState,
+ width: Int,
+ height: Int,
+ portrait: Boolean
+ ) {
+ val client = OkHttpClient()
+ val request = Request.Builder()
+ .url(link)
+ .header(HttpHeaders.Cookie, "jwt=$authToken")
+ .build()
+
+ runCatching {
+ if (state.isLoaded) {
+ coroutineScope.launch(Dispatchers.IO) {
+ runCatching {
+ val pFD =
+ ParcelFileDescriptor.open(
+ state.mFile,
+ ParcelFileDescriptor.MODE_READ_ONLY
+ )
+ state.pdfRender =
+ PdfRendering(pFD, width, height, portrait)
+ }.onFailure {
+ state.mError = it
+ }
+ }
+ } else {
+ coroutineScope.launch(Dispatchers.IO) {
+ runCatching {
+
+ val bufferSize = 8192
+ var downloaded = 0
+ val file = File(context.cacheDir, filename ?: generateFileName())
+ val response = client.newCall(request).execute()
+ if (!response.isSuccessful) {
+ state.mError = Exception("Failed to download PDF: ${response.code}")
+ Log.e("PdfView", "Failed to download PDF: ${response.code}")
+ }
+
+ val byteStream = response.body?.byteStream()
+ byteStream.use { input ->
+ file.outputStream().use { output ->
+ val totalBytes = response.body?.contentLength()
+ var data = ByteArray(bufferSize)
+ var count = input?.read(data)
+ while (count != -1) {
+ if (totalBytes != null) {
+ if (totalBytes > 0) {
+ downloaded += bufferSize
+ state.mLoadPercent =
+ (downloaded * (100 / totalBytes.toFloat())).toInt()
+ }
+ }
+ if (count != null) {
+ output.write(data, 0, count)
+ }
+ data = ByteArray(bufferSize)
+ count = input?.read(data)
+ }
+ }
+ }
+ val pFD = ParcelFileDescriptor.open(
+ file,
+ ParcelFileDescriptor.MODE_READ_ONLY
+ )
+ state.pdfRender =
+ PdfRendering(pFD, width, height, portrait)
+ state.mFile = file
+ }.onFailure {
+ state.mError = it
+ }
+ }
+ }
+ }.onFailure {
+ state.mError = it
+ }
+ }
+
+ fun downloadPdf(context: Context) {
+ try {
+ val downloadManager: DownloadManager =
+ context.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager
+ val downloadUri = Uri.parse(link)
+
+ downloadManager
+ .enqueue(
+ DownloadManager.Request(downloadUri)
+ .addRequestHeader(HttpHeaders.Cookie, "jwt=$authToken")
+ .setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED)
+ .setTitle(
+ filename ?: generateFileName()
+ )
+ .setDestinationInExternalPublicDir(
+ Environment.DIRECTORY_DOWNLOADS,
+ downloadUri.lastPathSegment
+ )
+ )
+
+ Toast.makeText(
+ context,
+ getString(context, R.string.pdf_view_downloading_toast),
+ Toast.LENGTH_SHORT
+ ).show()
+
+ } catch (e: Exception) {
+ val errorMessage = when (e) {
+ is UnknownHostException -> {
+ R.string.pdf_view_error_no_internet
+ }
+ else -> {
+ R.string.pdf_view_error_downloading
+ }
+ }
+ Toast.makeText(context, getString(context, errorMessage), Toast.LENGTH_LONG)
+ .show()
+ e.printStackTrace()
+ }
+ }
+
+ fun sharePdf(context: Context, pdfFile: File) {
+ val pdfUri = FileProvider.getUriForFile(
+ context,
+ "${context.packageName}.fileprovider",
+ pdfFile
+ )
+
+ val shareIntent = Intent(Intent.ACTION_SEND).apply {
+ type = "application/pdf"
+ clipData = ClipData(
+ "pdf",
+ arrayOf("application/pdf"),
+ ClipData.Item(pdfUri)
+ )
+ putExtra(Intent.EXTRA_STREAM, pdfUri) // to support sharing to older applications
+ addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
+ }
+ context.startActivity(
+ Intent.createChooser(
+ shareIntent,
+ getString(context, R.string.pdf_view_share_title)
+ )
+ )
+ }
+}
+
+@Composable
+internal fun PdfImage(
+ bitmap: () -> ImageBitmap,
+ contentDescription: String = "",
+) {
+ Image(
+ bitmap = bitmap(),
+ contentDescription = contentDescription,
+ contentScale = ContentScale.FillWidth,
+ modifier = Modifier.fillMaxWidth()
+ )
+}
\ No newline at end of file
diff --git a/core/ui/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/ui/pdf/render/PdfRendering.kt b/core/ui/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/ui/pdf/render/PdfRendering.kt
new file mode 100644
index 000000000..db32aa180
--- /dev/null
+++ b/core/ui/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/ui/pdf/render/PdfRendering.kt
@@ -0,0 +1,157 @@
+package de.tum.informatics.www1.artemis.native_app.core.ui.pdf.render
+
+import android.graphics.Bitmap
+import android.graphics.Canvas
+import android.graphics.pdf.PdfRenderer
+import android.os.ParcelFileDescriptor
+import androidx.core.graphics.createBitmap
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.cancelAndJoin
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
+
+internal class PdfRendering(
+ private val fileDescriptor: ParcelFileDescriptor,
+ val width: Int,
+ val height: Int,
+ private val portrait: Boolean
+) {
+ private val pdfRenderer = PdfRenderer(fileDescriptor)
+ val pageCount get() = pdfRenderer.pageCount
+ private val mutex: Mutex = Mutex()
+ private val coroutineScope = CoroutineScope(Dispatchers.Default + SupervisorJob())
+
+ val pageLists: List = List(pdfRenderer.pageCount) {
+ Page(
+ mutex = mutex,
+ index = it,
+ pdfRenderer = pdfRenderer,
+ coroutineScope = coroutineScope,
+ width = width,
+ height = height,
+ portrait = portrait
+ )
+ }
+
+ fun close() {
+ coroutineScope.launch {
+ pageLists.forEach {
+ it.job?.cancelAndJoin()
+ it.recycle()
+ }
+ pdfRenderer.close()
+ fileDescriptor.close()
+ }
+ }
+
+ class Page(
+ val mutex: Mutex,
+ val index: Int,
+ val pdfRenderer: PdfRenderer,
+ val coroutineScope: CoroutineScope,
+ width: Int,
+ height: Int,
+ portrait: Boolean
+ ) {
+ val dimension = pdfRenderer.openPage(index).use {
+ if (portrait) {
+ val h = it.height * (width.toFloat() / it.width)
+ val dim = Dimension(
+ height = h.toInt(),
+ width = width
+ )
+ dim
+ } else {
+ val w = it.width * (height.toFloat() / it.height)
+ val dim = Dimension(
+ height = height,
+ width = w.toInt()
+ )
+ dim
+ }
+ }
+
+ var job: Job? = null
+
+ val stateFlow = MutableStateFlow(
+ PageContentInt.BlankPage(
+ width = dimension.width,
+ height = dimension.height
+ )
+ )
+
+ private var isLoaded = false
+
+ fun load() {
+ if (!isLoaded) {
+ job = coroutineScope.launch {
+ mutex.withLock {
+ val newBitmap: Bitmap
+ pdfRenderer.openPage(index).use { currentPage ->
+ newBitmap = createBlankBitmap(
+ width = dimension.width,
+ height = dimension.height
+ )
+ currentPage.render(
+ newBitmap,
+ null,
+ null,
+ PdfRenderer.Page.RENDER_MODE_FOR_DISPLAY
+ )
+ }
+ isLoaded = true
+ stateFlow.emit(PageContentInt.PageContent(newBitmap))
+ }
+ }
+ }
+ }
+
+ fun recycle() {
+ isLoaded = false
+ val oldBitmap = stateFlow.value as? PageContentInt.PageContent
+ stateFlow.tryEmit(
+ PageContentInt.BlankPage(
+ width = dimension.width,
+ height = dimension.height
+ )
+ )
+ oldBitmap?.bitmap?.recycle()
+ }
+
+ private fun createBlankBitmap(
+ width: Int,
+ height: Int
+ ): Bitmap {
+ return createBitmap(
+ width,
+ height,
+ Bitmap.Config.ARGB_8888
+ ).apply {
+ val canvas = Canvas(this)
+ canvas.drawColor(android.graphics.Color.WHITE)
+ canvas.drawBitmap(this, 0f, 0f, null)
+ }
+ }
+
+ data class Dimension(
+ val height: Int,
+ val width: Int
+ )
+ }
+}
+
+sealed interface PageContentInt {
+ data class PageContent(
+ val bitmap: Bitmap
+ ) : PageContentInt
+
+ data class BlankPage(
+ val width: Int,
+ val height: Int
+ ) : PageContentInt
+}
\ No newline at end of file
diff --git a/core/ui/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/ui/pdf/render/PdfView.kt b/core/ui/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/ui/pdf/render/PdfView.kt
new file mode 100644
index 000000000..49996424a
--- /dev/null
+++ b/core/ui/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/ui/pdf/render/PdfView.kt
@@ -0,0 +1,319 @@
+package de.tum.informatics.www1.artemis.native_app.core.ui.pdf.render
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.gestures.detectTapGestures
+import androidx.compose.foundation.gestures.detectTransformGestures
+import androidx.compose.foundation.gestures.scrollBy
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.BoxWithConstraints
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.pager.HorizontalPager
+import androidx.compose.foundation.pager.rememberPagerState
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.snapshotFlow
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.composed
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.asImageBitmap
+import androidx.compose.ui.graphics.graphicsLayer
+import androidx.compose.ui.input.pointer.pointerInput
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.platform.debugInspectorInfo
+import androidx.compose.ui.unit.Constraints
+import androidx.compose.ui.unit.dp
+import de.tum.informatics.www1.artemis.native_app.core.ui.pdf.PdfImage
+import de.tum.informatics.www1.artemis.native_app.core.ui.pdf.render.state.HorizontalPdfReaderState
+import de.tum.informatics.www1.artemis.native_app.core.ui.pdf.render.state.VerticalPdfReaderState
+import kotlinx.coroutines.launch
+
+@Composable
+fun VerticalPdfView(
+ state: VerticalPdfReaderState,
+ modifier: Modifier
+) {
+ BoxWithConstraints(
+ modifier = modifier,
+ contentAlignment = Alignment.TopCenter
+ ) {
+ val ctx = LocalContext.current
+ val coroutineScope = rememberCoroutineScope()
+ val lazyState = state.lazyState
+ val pdfFile = state.pdfFile
+
+ DisposableEffect(key1 = Unit) {
+ pdfFile.load(
+ coroutineScope,
+ ctx,
+ state,
+ constraints.maxWidth,
+ constraints.maxHeight,
+ true
+ )
+ onDispose {
+ state.close()
+ }
+ }
+ state.pdfRender?.let { pdf ->
+ LazyColumn(
+ modifier = Modifier
+ .fillMaxSize()
+ .tapToZoomVertical(state, constraints),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.spacedBy(16.dp),
+ state = lazyState
+ ) {
+ items(pdf.pageCount) {
+ val pageContent = pdf.pageLists[it].stateFlow.collectAsState().value
+ DisposableEffect(key1 = Unit) {
+ pdf.pageLists[it].load()
+ onDispose {
+ // This caused issues and has therefore been temporarily disabled
+ //pdf.pageLists[it].recycle()
+ }
+ }
+ when (pageContent) {
+ is PageContentInt.PageContent -> {
+ PdfImage(
+ bitmap = { pageContent.bitmap.asImageBitmap() }
+ )
+ }
+
+ is PageContentInt.BlankPage -> BlackPage(
+ width = pageContent.width,
+ height = pageContent.height
+ )
+ }
+ }
+ }
+ }
+ }
+}
+
+@Composable
+fun HorizontalPdfView(
+ state: HorizontalPdfReaderState,
+ modifier: Modifier
+) {
+ BoxWithConstraints(
+ modifier = modifier,
+ contentAlignment = Alignment.TopCenter
+ ) {
+ val ctx = LocalContext.current
+ val coroutineScope = rememberCoroutineScope()
+ val pdfFile = state.pdfFile
+
+ DisposableEffect(key1 = Unit) {
+ pdfFile.load(
+ coroutineScope,
+ ctx,
+ state,
+ constraints.maxWidth,
+ constraints.maxHeight,
+ constraints.maxHeight > constraints.maxWidth
+ )
+ onDispose {
+ state.close()
+ }
+ }
+ val pagerState = rememberPagerState( state.currentPage - 1 ) { state.pdfPageCount }
+
+ state.pdfRender?.let { pdf ->
+ HorizontalPager(
+ modifier = Modifier
+ .fillMaxSize()
+ .tapToZoomHorizontal(state, constraints),
+ state = pagerState,
+ userScrollEnabled = state.scale == 1f
+ ) { page ->
+ val pageContent = pdf.pageLists[page].stateFlow.collectAsState().value
+
+ LaunchedEffect(pagerState) {
+ snapshotFlow { pagerState.currentPage }.collect { page ->
+ state.currentPage = page + 1
+ }
+ snapshotFlow { pagerState.isScrollInProgress }.collect { isScrolling ->
+ state.isScrolling = isScrolling
+ }
+ }
+
+ DisposableEffect(key1 = Unit) {
+ pdf.pageLists[page].load()
+ onDispose {
+ pdf.pageLists[page].recycle()
+ }
+ }
+ when (pageContent) {
+ is PageContentInt.PageContent -> {
+ PdfImage(
+ bitmap = { pageContent.bitmap.asImageBitmap() }
+ )
+ }
+
+ is PageContentInt.BlankPage -> BlackPage(
+ width = pageContent.width,
+ height = pageContent.height
+ )
+ }
+ }
+ }
+ }
+}
+
+fun Modifier.tapToZoomVertical(
+ state: VerticalPdfReaderState,
+ constraints: Constraints
+): Modifier = composed(
+ inspectorInfo = debugInspectorInfo {
+ name = "verticalTapToZoom"
+ properties["state"] = state
+ }
+) {
+ val coroutineScope = rememberCoroutineScope()
+ this
+ .pointerInput(Unit) {
+ detectTapGestures(
+ onDoubleTap = { tapCenter ->
+ if (!state.isZoomEnabled) return@detectTapGestures
+ if (state.mScale > 1.0f) {
+ state.mScale = 1.0f
+ state.offset = Offset(0f, 0f)
+ } else {
+ state.mScale = 3.0f
+ val center = Pair(constraints.maxWidth / 2, constraints.maxHeight / 2)
+ val xDiff = (tapCenter.x - center.first) * state.scale
+ val yDiff = ((tapCenter.y - center.second) * state.scale).coerceIn(
+ minimumValue = -(center.second * 2f),
+ maximumValue = (center.second * 2f)
+ )
+ state.offset = Offset(-xDiff, -yDiff)
+ }
+ }
+ )
+ }
+ .pointerInput(Unit) {
+ detectTransformGestures(true) { centroid, pan, zoom, rotation ->
+ val pair = if (pan.y > 0) {
+ if (state.lazyState.canScrollBackward) {
+ Pair(0f, pan.y)
+ } else {
+ Pair(pan.y, 0f)
+ }
+ } else {
+ if (state.lazyState.canScrollForward) {
+ Pair(0f, pan.y)
+ } else {
+ Pair(pan.y, 0f)
+ }
+ }
+ val nOffset = if (state.scale > 1f) {
+ val maxT = (constraints.maxWidth * state.scale) - constraints.maxWidth
+ val maxY = (constraints.maxHeight * state.scale) - constraints.maxHeight
+ Offset(
+ x = (state.offset.x + pan.x).coerceIn(
+ minimumValue = (-maxT / 2) * 1.3f,
+ maximumValue = (maxT / 2) * 1.3f
+ ),
+ y = (state.offset.y + pair.first).coerceIn(
+ minimumValue = (-maxY / 2),
+ maximumValue = (maxY / 2)
+ )
+ )
+ } else {
+ Offset(0f, 0f)
+ }
+ state.offset = nOffset
+ coroutineScope.launch {
+ state.lazyState.scrollBy((-pair.second / state.scale))
+ }
+ }
+ }
+ .graphicsLayer {
+ scaleX = state.scale
+ scaleY = state.scale
+ translationX = state.offset.x
+ translationY = state.offset.y
+ }
+}
+
+fun Modifier.tapToZoomHorizontal(
+ state: HorizontalPdfReaderState,
+ constraints: Constraints
+): Modifier = composed(
+ inspectorInfo = debugInspectorInfo {
+ name = "horizontalTapToZoom"
+ properties["state"] = state
+ }
+) {
+ this
+ .pointerInput(Unit) {
+ detectTapGestures(
+ onDoubleTap = { tapCenter ->
+ if (!state.isZoomEnabled) return@detectTapGestures
+ if (state.mScale > 1.0f) {
+ state.mScale = 1.0f
+ state.offset = Offset(0f, 0f)
+ } else {
+ state.mScale = 3.0f
+ val center = Pair(constraints.maxWidth / 2, constraints.maxHeight / 2)
+ val xDiff = (tapCenter.x - center.first) * state.scale
+ val yDiff = ((tapCenter.y - center.second) * state.scale).coerceIn(
+ minimumValue = -(center.second * 2f),
+ maximumValue = (center.second * 2f)
+ )
+ state.offset = Offset(-xDiff, -yDiff)
+ }
+ }
+ )
+ }
+ .pointerInput(Unit) {
+ detectTransformGestures(true) { centroid, pan, zoom, rotation ->
+ val nOffset = if (state.scale > 1f) {
+ val maxT = (constraints.maxWidth * state.scale) - constraints.maxWidth
+ val maxY = (constraints.maxHeight * state.scale) - constraints.maxHeight
+ Offset(
+ x = (state.offset.x + pan.x).coerceIn(
+ minimumValue = (-maxT / 2) * 1.3f,
+ maximumValue = (maxT / 2) * 1.3f
+ ),
+ y = (state.offset.y + pan.y).coerceIn(
+ minimumValue = (-maxY / 2) * 1.3f,
+ maximumValue = (maxY / 2) * 1.3f
+ )
+ )
+ } else {
+ Offset(0f, 0f)
+ }
+ state.offset = nOffset
+ }
+ }
+ .graphicsLayer {
+ scaleX = state.scale
+ scaleY = state.scale
+ translationX = state.offset.x
+ translationY = state.offset.y
+ }
+}
+
+@Composable
+fun BlackPage(
+ width: Int,
+ height: Int
+) {
+ Box(
+ modifier = Modifier
+ .size(
+ width = width.dp,
+ height = height.dp
+ )
+ .background(color = Color.White)
+ )
+}
\ No newline at end of file
diff --git a/core/ui/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/ui/pdf/render/state/HorizontalPdfReaderState.kt b/core/ui/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/ui/pdf/render/state/HorizontalPdfReaderState.kt
new file mode 100644
index 000000000..cf4d03a29
--- /dev/null
+++ b/core/ui/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/ui/pdf/render/state/HorizontalPdfReaderState.kt
@@ -0,0 +1,46 @@
+package de.tum.informatics.www1.artemis.native_app.core.ui.pdf.render.state
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.saveable.Saver
+import androidx.compose.runtime.saveable.listSaver
+import androidx.compose.runtime.saveable.rememberSaveable
+import de.tum.informatics.www1.artemis.native_app.core.ui.pdf.PdfFile
+
+class HorizontalPdfReaderState(
+ pdfFile: PdfFile,
+ isZoomEnabled: Boolean = false
+) : PdfReaderState(pdfFile, isZoomEnabled) {
+
+ companion object {
+ val Saver: Saver = listSaver(
+ save = { state ->
+ listOf(
+ state.pdfFile.link,
+ state.pdfFile.authToken,
+ state.pdfFile.filename,
+ state.isZoomEnabled
+ )
+ },
+ restore = { restoredList ->
+ val pdfFile = PdfFile(restoredList[0] as String, restoredList[1] as String, restoredList[2] as String?)
+ val isZoomEnabled = restoredList[3] as Boolean
+
+ HorizontalPdfReaderState(
+ pdfFile = pdfFile,
+ isZoomEnabled = isZoomEnabled
+ )
+ }
+ )
+ }
+}
+
+@Composable
+fun rememberHorizontalPdfReaderState(
+ pdfFile: PdfFile,
+ isZoomEnabled: Boolean = true,
+): HorizontalPdfReaderState {
+ return rememberSaveable(saver = HorizontalPdfReaderState.Saver) {
+ HorizontalPdfReaderState(pdfFile, isZoomEnabled)
+ }
+}
+
diff --git a/core/ui/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/ui/pdf/render/state/PdfReaderState.kt b/core/ui/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/ui/pdf/render/state/PdfReaderState.kt
new file mode 100644
index 000000000..ec8cd90b9
--- /dev/null
+++ b/core/ui/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/ui/pdf/render/state/PdfReaderState.kt
@@ -0,0 +1,55 @@
+package de.tum.informatics.www1.artemis.native_app.core.ui.pdf.render.state
+
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableFloatStateOf
+import androidx.compose.runtime.mutableIntStateOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.geometry.Offset
+import de.tum.informatics.www1.artemis.native_app.core.ui.pdf.PdfFile
+import de.tum.informatics.www1.artemis.native_app.core.ui.pdf.render.PdfRendering
+import java.io.File
+
+abstract class PdfReaderState(
+ val pdfFile: PdfFile,
+ isZoomEnabled: Boolean = false
+) {
+ internal var mError by mutableStateOf(null)
+ val error: Throwable?
+ get() = mError
+
+ private var mIsZoomEnable by mutableStateOf(isZoomEnabled)
+ val isZoomEnabled: Boolean
+ get() = mIsZoomEnable
+
+ internal var mScale by mutableFloatStateOf(1f)
+ val scale: Float
+ get() = mScale
+
+ internal var offset by mutableStateOf(Offset(0f, 0f))
+
+ internal var mFile by mutableStateOf(null)
+ val file: File?
+ get() = mFile
+
+ internal var pdfRender by mutableStateOf(null)
+
+ internal var mLoadPercent by mutableIntStateOf(0)
+ val loadPercent: Int
+ get() = mLoadPercent
+
+ val pdfPageCount: Int
+ get() = pdfRender?.pageCount ?: 0
+
+ open var currentPage by mutableIntStateOf(1)
+
+ val isLoaded
+ get() = mFile != null
+
+ open var isScrolling by mutableStateOf(false)
+
+ fun close() {
+ pdfRender?.close()
+ pdfRender = null
+ }
+}
\ No newline at end of file
diff --git a/core/ui/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/ui/pdf/render/state/VerticalPdfReaderState.kt b/core/ui/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/ui/pdf/render/state/VerticalPdfReaderState.kt
new file mode 100644
index 000000000..f0c226915
--- /dev/null
+++ b/core/ui/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/ui/pdf/render/state/VerticalPdfReaderState.kt
@@ -0,0 +1,78 @@
+package de.tum.informatics.www1.artemis.native_app.core.ui.pdf.render.state
+
+import androidx.compose.foundation.lazy.LazyListState
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.saveable.Saver
+import androidx.compose.runtime.saveable.listSaver
+import androidx.compose.runtime.saveable.rememberSaveable
+import de.tum.informatics.www1.artemis.native_app.core.ui.pdf.PdfFile
+
+class VerticalPdfReaderState(
+ pdfFile: PdfFile,
+ isZoomEnabled: Boolean = false,
+) : PdfReaderState(pdfFile, isZoomEnabled) {
+
+ internal var lazyState: LazyListState = LazyListState()
+ private set
+
+ override var currentPage: Int = currentPage()
+ get() = currentPage()
+
+ override var isScrolling: Boolean = lazyState.isScrollInProgress
+
+ private fun currentPage(): Int {
+ return pdfRender?.let { pdfRender ->
+ val currentMinIndex = lazyState.firstVisibleItemIndex
+ var lastVisibleIndex = currentMinIndex
+ var totalVisiblePortion =
+ (pdfRender.pageLists[currentMinIndex].dimension.height * scale) - lazyState.firstVisibleItemScrollOffset
+ for (i in currentMinIndex + 1 until pdfPageCount) {
+ val newTotalVisiblePortion =
+ totalVisiblePortion + (pdfRender.pageLists[i].dimension.height * scale)
+ if (newTotalVisiblePortion <= pdfRender.height) {
+ lastVisibleIndex = i
+ totalVisiblePortion = newTotalVisiblePortion
+ } else {
+ break
+ }
+ }
+ lastVisibleIndex + 1
+ } ?: 0
+ }
+
+ companion object {
+ val Saver: Saver = listSaver(
+ save = { state ->
+ listOf(
+ state.pdfFile.link,
+ state.pdfFile.authToken,
+ state.pdfFile.filename,
+ state.isZoomEnabled,
+ state.lazyState.firstVisibleItemIndex,
+ state.lazyState.firstVisibleItemScrollOffset
+ )
+ },
+ restore = { restoredList ->
+ VerticalPdfReaderState(
+ PdfFile(restoredList[0] as String, restoredList[1] as String, restoredList[2] as String?),
+ restoredList[3] as Boolean,
+ ).apply {
+ lazyState = LazyListState(
+ firstVisibleItemIndex = restoredList[4] as Int,
+ firstVisibleItemScrollOffset = restoredList[5] as Int
+ )
+ }
+ }
+ )
+ }
+}
+
+@Composable
+fun rememberVerticalPdfReaderState(
+ pdfFile: PdfFile,
+ isZoomEnabled: Boolean = true,
+): VerticalPdfReaderState {
+ return rememberSaveable(saver = VerticalPdfReaderState.Saver) {
+ VerticalPdfReaderState(pdfFile, isZoomEnabled)
+ }
+}
diff --git a/core/ui/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/ui/ui_module.kt b/core/ui/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/ui/ui_module.kt
index 8ad2090fa..35fd00843 100644
--- a/core/ui/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/ui/ui_module.kt
+++ b/core/ui/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/ui/ui_module.kt
@@ -1,9 +1,12 @@
package de.tum.informatics.www1.artemis.native_app.core.ui
+import de.tum.informatics.www1.artemis.native_app.core.ui.markdown.link_resolving.MarkdownLinkResolver
+import de.tum.informatics.www1.artemis.native_app.core.ui.markdown.link_resolving.MarkdownLinkResolverImpl
import de.tum.informatics.www1.artemis.native_app.core.ui.remote_images.ArtemisImageProvider
import de.tum.informatics.www1.artemis.native_app.core.ui.remote_images.impl.ArtemisImageProviderImpl
import org.koin.dsl.module
val uiModule = module {
single { ArtemisImageProviderImpl(get(), get()) }
+ single { MarkdownLinkResolverImpl(get(), get()) }
}
\ No newline at end of file
diff --git a/core/ui/src/main/res/drawable/rotate.xml b/core/ui/src/main/res/drawable/rotate.xml
new file mode 100644
index 000000000..bca1172a0
--- /dev/null
+++ b/core/ui/src/main/res/drawable/rotate.xml
@@ -0,0 +1,12 @@
+
+
+
+
+
+
diff --git a/core/ui/src/main/res/values/pdf_viewer_strings.xml b/core/ui/src/main/res/values/pdf_viewer_strings.xml
new file mode 100644
index 000000000..d1acd8363
--- /dev/null
+++ b/core/ui/src/main/res/values/pdf_viewer_strings.xml
@@ -0,0 +1,17 @@
+
+
+
+ Share PDF
+ Download PDF
+ Toggle View Mode
+
+ Page %1d/%2d
+
+ Downloading to Downloads folder...
+ Share PDF using
+
+ Error while loading PDF
+ Error: No internet connection
+ Error while downloading PDF
+
+
\ No newline at end of file
diff --git a/core/ui/src/main/res/xml/file_paths.xml b/core/ui/src/main/res/xml/file_paths.xml
new file mode 100644
index 000000000..d2e2c8dea
--- /dev/null
+++ b/core/ui/src/main/res/xml/file_paths.xml
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/feature/exercise-view/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/exerciseview/ExerciseWebViewUtils.kt b/feature/exercise-view/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/exerciseview/ExerciseWebViewUtils.kt
new file mode 100644
index 000000000..ee2b30aff
--- /dev/null
+++ b/feature/exercise-view/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/exerciseview/ExerciseWebViewUtils.kt
@@ -0,0 +1,83 @@
+package de.tum.informatics.www1.artemis.native_app.feature.exerciseview
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.derivedStateOf
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.remember
+import com.google.accompanist.web.WebContent
+import com.google.accompanist.web.WebViewState
+import de.tum.informatics.www1.artemis.native_app.core.ui.exercise.ResultTemplateStatus
+import io.ktor.http.URLBuilder
+import io.ktor.http.appendPathSegments
+
+@Composable
+internal fun getProblemStatementWebViewState(
+ serverUrl: String,
+ courseId: Long?,
+ exerciseId: Long?,
+ participationId: Long?
+): WebViewState? {
+ val url by remember(serverUrl, courseId, exerciseId) {
+ derivedStateOf {
+ if (courseId != null && exerciseId != null) {
+ URLBuilder(serverUrl).apply {
+ appendPathSegments(
+ "courses",
+ courseId.toString(),
+ "exercises",
+ exerciseId.toString(),
+ "problem-statement"
+ )
+
+ if (participationId != null) {
+ appendPathSegments(participationId.toString())
+ }
+ }
+ .buildString()
+ } else null
+ }
+ }
+
+ return remember(url) {
+ derivedStateOf {
+ url?.let {
+ WebViewState(WebContent.Url(url = it))
+ }
+ }
+ }.value
+}
+
+@Composable
+internal fun getFeedbackViewWebViewState(
+ serverUrl: String,
+ courseId: Long,
+ exerciseId: Long,
+ participationId: Long,
+ resultId: Long,
+ templateStatus: ResultTemplateStatus
+): WebViewState {
+ val url by remember(serverUrl, courseId, exerciseId, resultId, templateStatus) {
+ derivedStateOf {
+ URLBuilder(serverUrl).apply {
+ appendPathSegments(
+ "courses",
+ courseId.toString(),
+ "exercises",
+ exerciseId.toString(),
+ "feedback",
+ participationId.toString(),
+ resultId.toString(),
+ (templateStatus == ResultTemplateStatus.Missing).toString()
+ )
+ }.buildString()
+ }
+ }
+
+ return remember(url) {
+ derivedStateOf {
+ url.let {
+ WebViewState(WebContent.Url(url = it))
+ }
+ }
+ }.value
+}
\ No newline at end of file
diff --git a/feature/exercise-view/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/exerciseview/home/overview/ExerciseOverviewTab.kt b/feature/exercise-view/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/exerciseview/home/overview/ExerciseOverviewTab.kt
index 3ba65fbb0..12a2ef7ae 100644
--- a/feature/exercise-view/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/exerciseview/home/overview/ExerciseOverviewTab.kt
+++ b/feature/exercise-view/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/exerciseview/home/overview/ExerciseOverviewTab.kt
@@ -32,6 +32,7 @@ import com.google.accompanist.web.WebViewState
import de.tum.informatics.www1.artemis.native_app.core.model.exercise.Exercise
import de.tum.informatics.www1.artemis.native_app.core.model.exercise.QuizExercise
import de.tum.informatics.www1.artemis.native_app.core.model.exercise.currentUserPoints
+import de.tum.informatics.www1.artemis.native_app.core.ui.compose.ArtemisWebView
import de.tum.informatics.www1.artemis.native_app.core.ui.date.getRelativeTime
import de.tum.informatics.www1.artemis.native_app.core.ui.date.hasPassed
import de.tum.informatics.www1.artemis.native_app.core.ui.exercise.ExerciseActions
@@ -39,7 +40,6 @@ import de.tum.informatics.www1.artemis.native_app.core.ui.exercise.ExerciseCateg
import de.tum.informatics.www1.artemis.native_app.core.ui.exercise.ExerciseInfoChip
import de.tum.informatics.www1.artemis.native_app.core.ui.exercise.ExerciseInfoChipTextHorizontalPadding
import de.tum.informatics.www1.artemis.native_app.core.ui.exercise.ExercisePointsDecimalFormat
-import de.tum.informatics.www1.artemis.native_app.feature.exerciseview.ArtemisWebView
import de.tum.informatics.www1.artemis.native_app.feature.exerciseview.R
import kotlinx.datetime.Instant
diff --git a/feature/exercise-view/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/exerciseview/participate/textexercise/TextExerciseParticipationScreen.kt b/feature/exercise-view/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/exerciseview/participate/textexercise/TextExerciseParticipationScreen.kt
index 15cc73b6d..931abb3b3 100644
--- a/feature/exercise-view/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/exerciseview/participate/textexercise/TextExerciseParticipationScreen.kt
+++ b/feature/exercise-view/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/exerciseview/participate/textexercise/TextExerciseParticipationScreen.kt
@@ -41,9 +41,9 @@ import de.tum.informatics.www1.artemis.native_app.core.model.exercise.Exercise
import de.tum.informatics.www1.artemis.native_app.core.model.exercise.participation.Participation
import de.tum.informatics.www1.artemis.native_app.core.model.exercise.participation.isInitializationAfterDueDate
import de.tum.informatics.www1.artemis.native_app.core.model.exercise.submission.Result
+import de.tum.informatics.www1.artemis.native_app.core.ui.compose.ArtemisWebView
import de.tum.informatics.www1.artemis.native_app.core.ui.date.isInFuture
import de.tum.informatics.www1.artemis.native_app.core.ui.getWindowSizeClass
-import de.tum.informatics.www1.artemis.native_app.feature.exerciseview.ArtemisWebView
import de.tum.informatics.www1.artemis.native_app.feature.exerciseview.R
import de.tum.informatics.www1.artemis.native_app.feature.exerciseview.getProblemStatementWebViewState
diff --git a/feature/exercise-view/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/exerciseview/viewresult/ViewResultScreen.kt b/feature/exercise-view/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/exerciseview/viewresult/ViewResultScreen.kt
index 03cf98a73..20a1fdac8 100644
--- a/feature/exercise-view/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/exerciseview/viewresult/ViewResultScreen.kt
+++ b/feature/exercise-view/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/exerciseview/viewresult/ViewResultScreen.kt
@@ -22,9 +22,9 @@ import de.tum.informatics.www1.artemis.native_app.core.data.join
import de.tum.informatics.www1.artemis.native_app.core.model.exercise.latestParticipation
import de.tum.informatics.www1.artemis.native_app.core.ui.Spacings
import de.tum.informatics.www1.artemis.native_app.core.ui.common.EmptyDataStateUi
+import de.tum.informatics.www1.artemis.native_app.core.ui.compose.ArtemisWebView
import de.tum.informatics.www1.artemis.native_app.core.ui.exercise.LocalTemplateStatusProvider
import de.tum.informatics.www1.artemis.native_app.core.ui.exercise.ProvideDefaultExerciseTemplateStatus
-import de.tum.informatics.www1.artemis.native_app.feature.exerciseview.ArtemisWebView
import de.tum.informatics.www1.artemis.native_app.feature.exerciseview.ExerciseViewModel
import de.tum.informatics.www1.artemis.native_app.feature.exerciseview.R
import de.tum.informatics.www1.artemis.native_app.feature.exerciseview.getFeedbackViewWebViewState