Upload File dan Summary

This commit is contained in:
202310715297 RAIHAN ARIQ MUZAKKI 2025-12-22 15:25:34 +07:00
parent 978b4285bb
commit 2ee345e250
10 changed files with 731 additions and 115 deletions

View File

@ -81,9 +81,29 @@ dependencies {
debugImplementation("androidx.compose.ui:ui-tooling")
debugImplementation("androidx.compose.ui:ui-test-manifest")
// Gemini AI
implementation("com.google.ai.client.generativeai:generativeai:0.1.2")
// File picker
implementation("androidx.activity:activity-compose:1.8.2")
// JSON parsing (untuk tool calls)
implementation("org.json:json:20230227")
// PDF Parser (ONLY THIS ONE!)
implementation("com.tom-roush:pdfbox-android:2.0.27.0")
// File operations
implementation("androidx.documentfile:documentfile:1.0.1")
}
android {
packaging {
resources {
excludes += "/META-INF/{AL2.0,LGPL2.1}"
excludes += "/META-INF/DEPENDENCIES"
excludes += "/META-INF/LICENSE"
excludes += "/META-INF/LICENSE.txt"
excludes += "/META-INF/license.txt"
excludes += "/META-INF/NOTICE"
excludes += "/META-INF/NOTICE.txt"
excludes += "/META-INF/notice.txt"
excludes += "/META-INF/ASL2.0"
excludes += "/META-INF/*.kotlin_module"
}
}
}

View File

@ -3,8 +3,17 @@
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" />
<!-- Read files -->
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"
android:maxSdkVersion="32" />
<!-- For Android 13+ -->
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO" />
<uses-permission android:name="android.permission.READ_MEDIA_AUDIO" />
<application
android:name=".NotesAIApplication"
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"

View File

@ -90,6 +90,15 @@ class MainActivity : ComponentActivity() {
}
}
fun sortCategories(categories: List<Category>): List<Category> {
return categories
.filter { !it.isDeleted }
.sortedWith(
compareByDescending<Category> { it.isPinned } // Pinned dulu
.thenByDescending { it.timestamp } // Lalu timestamp
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun NotesApp() {

View File

@ -0,0 +1,13 @@
package com.example.notesai
import android.app.Application
import com.example.notesai.util.FileParser
class NotesAIApplication : Application() {
override fun onCreate() {
super.onCreate()
// Initialize PDFBox
FileParser.initPDFBox(this)
}
}

View File

@ -30,7 +30,8 @@ data class SerializableCategory(
val gradientStart: Long,
val gradientEnd: Long,
val timestamp: Long,
val isDeleted: Boolean = false
val isDeleted: Boolean = false,
val isPinned: Boolean = false // NEW
)
@Serializable
@ -77,7 +78,8 @@ class DataStoreManager(private val context: Context) {
gradientStart = it.gradientStart,
gradientEnd = it.gradientEnd,
timestamp = it.timestamp,
isDeleted = it.isDeleted
isDeleted = it.isDeleted,
isPinned = it.isPinned // NEW
)
}
} catch (e: Exception) {
@ -147,7 +149,8 @@ class DataStoreManager(private val context: Context) {
gradientStart = it.gradientStart,
gradientEnd = it.gradientEnd,
timestamp = it.timestamp,
isDeleted = it.isDeleted
isDeleted = it.isDeleted,
isPinned = it.isPinned // NEW
)
}
preferences[CATEGORIES_KEY] = json.encodeToString(serializable)

View File

@ -10,5 +10,6 @@ data class Category(
val gradientStart: Long,
val gradientEnd: Long,
val timestamp: Long = System.currentTimeMillis(),
val isDeleted: Boolean = false // Support soft delete
val isDeleted: Boolean = false,
val isPinned: Boolean = false // NEW: Tambahkan ini
)

View File

@ -3,7 +3,6 @@ package com.example.notesai.data.model
import android.annotation.SuppressLint
import kotlinx.serialization.Serializable
@SuppressLint("UnsafeOptInUsageError")
@Serializable
data class SerializableCategory(
val id: String,
@ -11,7 +10,8 @@ data class SerializableCategory(
val gradientStart: Long,
val gradientEnd: Long,
val timestamp: Long,
val isDeleted: Boolean = false // TAMBAHKAN INI
val isDeleted: Boolean = false,
val isPinned: Boolean = false // NEW: Tambahkan ini
)
@SuppressLint("UnsafeOptInUsageError")
@ -34,7 +34,8 @@ fun Category.toSerializable() = SerializableCategory(
gradientStart = gradientStart,
gradientEnd = gradientEnd,
timestamp = timestamp,
isDeleted = isDeleted // TAMBAHKAN INI
isDeleted = isDeleted,
isPinned = isPinned // NEW: Tambahkan ini
)
fun SerializableCategory.toCategory() = Category(
@ -43,7 +44,8 @@ fun SerializableCategory.toCategory() = Category(
gradientStart = gradientStart,
gradientEnd = gradientEnd,
timestamp = timestamp,
isDeleted = isDeleted // TAMBAHKAN INI
isDeleted = isDeleted,
isPinned = isPinned // NEW: Tambahkan ini
)
fun Note.toSerializable() = SerializableNote(

View File

@ -14,7 +14,6 @@ import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalContext
@ -32,11 +31,9 @@ import com.google.ai.client.generativeai.type.generationConfig
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import java.util.*
import com.example.notesai.presentation.screens.ai.components.ChatBubble
import com.example.notesai.presentation.screens.ai.components.ChatHistoryDrawer
import com.example.notesai.presentation.screens.ai.components.CompactStatItem
import com.example.notesai.presentation.screens.ai.components.SuggestionChip
import com.example.notesai.presentation.screens.ai.components.*
import com.example.notesai.util.AppColors
import com.example.notesai.util.FileParseResult
private const val MAX_CHAT_TITLE_LENGTH = 30
@ -67,6 +64,10 @@ fun AIHelperScreen(
var showHistoryDrawer by remember { mutableStateOf(false) }
var currentChatId by remember { mutableStateOf<String?>(null) }
// NEW: File Upload States
var uploadedFile by remember { mutableStateOf<FileParseResult.Success?>(null) }
var isGeneratingSummary by remember { mutableStateOf(false) }
val scope = rememberCoroutineScope()
val clipboardManager = LocalClipboardManager.current
val scrollState = rememberScrollState()
@ -134,6 +135,8 @@ fun AIHelperScreen(
currentChatId = null
errorMessage = ""
showHistoryDrawer = false
uploadedFile = null
isGeneratingSummary = false
}
Box(modifier = Modifier.fillMaxSize()) {
@ -277,7 +280,8 @@ fun AIHelperScreen(
Column(
modifier = Modifier
.fillMaxSize()
.padding(32.dp),
.padding(32.dp)
.verticalScroll(rememberScrollState()),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
@ -333,6 +337,70 @@ fun AIHelperScreen(
SuggestionChip("Buat ringkasan") { prompt = it }
SuggestionChip("Berikan saran organisasi") { prompt = it }
}
Spacer(modifier = Modifier.height(24.dp))
// NEW: File Upload Button
FileUploadButton(
onFileSelected = { fileResult ->
uploadedFile = fileResult
// Auto-generate summary
scope.launch {
isGeneratingSummary = true
try {
val summaryPrompt = """
Buatkan ringkasan dari dokumen berikut dalam bahasa Indonesia:
Judul File: ${fileResult.fileName}
Tipe: ${fileResult.fileType}
Jumlah Kata: ${fileResult.wordCount}
Konten:
${fileResult.content}
Buat ringkasan yang:
1. Mencakup poin-poin utama
2. Terstruktur dengan baik (gunakan markdown)
3. Mudah dipahami
4. Maksimal 300 kata
""".trimIndent()
// Add user message
chatMessages = chatMessages + ChatMessage(
message = "📄 Upload file: ${fileResult.fileName}\n\nMohon buatkan ringkasan dari file ini.",
isUser = true
)
// Scroll to bottom to show loading
delay(100)
scrollState.animateScrollTo(scrollState.maxValue)
// Generate summary dengan Gemini
val response = generativeModel.generateContent(summaryPrompt)
val summary = response.text ?: "Gagal membuat ringkasan"
// Add AI response
chatMessages = chatMessages + ChatMessage(
message = summary,
isUser = false
)
saveChatHistory()
} catch (e: Exception) {
errorMessage = "⚠️ Gagal membuat ringkasan: ${e.message}"
} finally {
isGeneratingSummary = false
uploadedFile = null
}
}
},
onError = { error ->
errorMessage = error
},
modifier = Modifier.fillMaxWidth()
)
}
} else {
// Chat Messages
@ -345,7 +413,7 @@ fun AIHelperScreen(
chatMessages.forEach { message ->
ChatBubble(
message = message,
onCopy = { textToCopy -> // CHANGED: Sekarang terima parameter text
onCopy = { textToCopy ->
clipboardManager.setText(AnnotatedString(textToCopy))
copiedMessageId = message.id
showCopiedMessage = true
@ -359,8 +427,8 @@ fun AIHelperScreen(
Spacer(modifier = Modifier.height(12.dp))
}
// Loading Indicator
if (isLoading) {
// Loading Indicator (untuk chat biasa DAN file summary)
if (isLoading || isGeneratingSummary) {
Row(
modifier = Modifier
.fillMaxWidth()
@ -378,11 +446,11 @@ fun AIHelperScreen(
) {
CircularProgressIndicator(
modifier = Modifier.size(20.dp),
color = AppColors.Primary,
color = if (isGeneratingSummary) AppColors.Secondary else AppColors.Primary,
strokeWidth = 2.dp
)
Text(
"AI sedang berpikir...",
if (isGeneratingSummary) "Membuat ringkasan..." else "AI sedang berpikir...",
color = AppColors.OnSurfaceVariant,
style = MaterialTheme.typography.bodyMedium
)
@ -427,118 +495,182 @@ fun AIHelperScreen(
shadowElevation = 8.dp,
shape = RoundedCornerShape(topStart = 20.dp, topEnd = 20.dp)
) {
Row(
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalAlignment = Alignment.Bottom,
horizontalArrangement = Arrangement.spacedBy(12.dp)
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
OutlinedTextField(
value = prompt,
onValueChange = { prompt = it },
placeholder = {
Text(
"Ketik pesan...",
color = AppColors.OnSurfaceTertiary
)
},
modifier = Modifier
.weight(1f)
.heightIn(min = 48.dp, max = 120.dp),
colors = OutlinedTextFieldDefaults.colors(
focusedTextColor = AppColors.OnBackground,
unfocusedTextColor = AppColors.OnSurface,
focusedContainerColor = AppColors.SurfaceVariant,
unfocusedContainerColor = AppColors.SurfaceVariant,
cursorColor = AppColors.Primary,
focusedBorderColor = AppColors.Primary,
unfocusedBorderColor = Color.Transparent
),
shape = RoundedCornerShape(24.dp),
maxLines = 4
)
// NEW: Upload File Button (di atas input text)
if (chatMessages.isNotEmpty() && !isGeneratingSummary && !isLoading) {
FileUploadButton(
onFileSelected = { fileResult ->
uploadedFile = fileResult
// Send Button
FloatingActionButton(
onClick = {
if (prompt.isNotBlank() && !isLoading) {
scope.launch {
chatMessages = chatMessages + ChatMessage(
message = prompt,
isUser = true
)
val userPrompt = prompt
prompt = ""
isLoading = true
errorMessage = ""
isGeneratingSummary = true
try {
val filteredNotes = if (selectedCategory != null) {
notes.filter { it.categoryId == selectedCategory!!.id && !it.isArchived }
} else {
notes.filter { !it.isArchived }
}
val notesContext = buildString {
appendLine("Data catatan pengguna:")
appendLine("Total catatan: ${filteredNotes.size}")
appendLine("Kategori: ${selectedCategory?.name ?: "Semua"}")
appendLine()
appendLine("Daftar catatan:")
filteredNotes.take(10).forEach { note ->
appendLine("- Judul: ${note.title}")
appendLine(" Isi: ${note.content.take(100)}")
appendLine()
}
}
val fullPrompt = "$notesContext\n\nPertanyaan: $userPrompt\n\nBerikan jawaban dalam bahasa Indonesia yang natural dan membantu."
val result = generativeModel.generateContent(fullPrompt)
val response = result.text ?: "Tidak ada respons dari AI"
val summaryPrompt = """
Buatkan ringkasan dari dokumen berikut dalam bahasa Indonesia:
Judul File: ${fileResult.fileName}
Tipe: ${fileResult.fileType}
Jumlah Kata: ${fileResult.wordCount}
Konten:
${fileResult.content}
Buat ringkasan yang:
1. Mencakup poin-poin utama
2. Terstruktur dengan baik (gunakan markdown)
3. Mudah dipahami
4. Maksimal 300 kata
""".trimIndent()
chatMessages = chatMessages + ChatMessage(
message = response,
message = "📄 Upload file: ${fileResult.fileName}\n\nMohon buatkan ringkasan dari file ini.",
isUser = true
)
// Scroll to show loading
delay(100)
scrollState.animateScrollTo(scrollState.maxValue)
val response = generativeModel.generateContent(summaryPrompt)
val summary = response.text ?: "Gagal membuat ringkasan"
chatMessages = chatMessages + ChatMessage(
message = summary,
isUser = false
)
// Auto-save chat history
saveChatHistory()
} catch (e: Exception) {
// Better error handling
errorMessage = when {
e.message?.contains("quota", ignoreCase = true) == true ->
"⚠️ Kuota API habis. Silakan coba lagi nanti atau hubungi developer."
e.message?.contains("404", ignoreCase = true) == true ||
e.message?.contains("not found", ignoreCase = true) == true ->
"⚠️ Model AI tidak ditemukan. Silakan hubungi developer."
e.message?.contains("401", ignoreCase = true) == true ||
e.message?.contains("API key", ignoreCase = true) == true ->
"⚠️ API key tidak valid. Silakan hubungi developer."
e.message?.contains("timeout", ignoreCase = true) == true ->
"⚠️ Koneksi timeout. Periksa koneksi internet Anda."
e.message?.contains("network", ignoreCase = true) == true ->
"⚠️ Tidak ada koneksi internet. Silakan periksa koneksi Anda."
else ->
"⚠️ Terjadi kesalahan: ${e.message?.take(100) ?: "Unknown error"}"
}
errorMessage = "⚠️ Gagal membuat ringkasan: ${e.message}"
} finally {
isLoading = false
isGeneratingSummary = false
uploadedFile = null
}
}
}
},
containerColor = AppColors.Primary,
modifier = Modifier.size(48.dp)
) {
Icon(
Icons.Default.Send,
contentDescription = "Send",
tint = Color.White,
modifier = Modifier.size(20.dp)
},
onError = { error ->
errorMessage = error
},
modifier = Modifier.fillMaxWidth()
)
}
// Text Input & Send Button
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.Bottom,
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
OutlinedTextField(
value = prompt,
onValueChange = { prompt = it },
placeholder = {
Text(
"Ketik pesan...",
color = AppColors.OnSurfaceTertiary
)
},
modifier = Modifier
.weight(1f)
.heightIn(min = 48.dp, max = 120.dp),
colors = OutlinedTextFieldDefaults.colors(
focusedTextColor = AppColors.OnBackground,
unfocusedTextColor = AppColors.OnSurface,
focusedContainerColor = AppColors.SurfaceVariant,
unfocusedContainerColor = AppColors.SurfaceVariant,
cursorColor = AppColors.Primary,
focusedBorderColor = AppColors.Primary,
unfocusedBorderColor = Color.Transparent
),
shape = RoundedCornerShape(24.dp),
maxLines = 4
)
// Send Button
FloatingActionButton(
onClick = {
if (prompt.isNotBlank() && !isLoading) {
scope.launch {
chatMessages = chatMessages + ChatMessage(
message = prompt,
isUser = true
)
val userPrompt = prompt
prompt = ""
isLoading = true
errorMessage = ""
try {
val filteredNotes = if (selectedCategory != null) {
notes.filter { it.categoryId == selectedCategory!!.id && !it.isArchived }
} else {
notes.filter { !it.isArchived }
}
val notesContext = buildString {
appendLine("Data catatan pengguna:")
appendLine("Total catatan: ${filteredNotes.size}")
appendLine("Kategori: ${selectedCategory?.name ?: "Semua"}")
appendLine()
appendLine("Daftar catatan:")
filteredNotes.take(10).forEach { note ->
appendLine("- Judul: ${note.title}")
appendLine(" Isi: ${note.content.take(100)}")
appendLine()
}
}
val fullPrompt = "$notesContext\n\nPertanyaan: $userPrompt\n\nBerikan jawaban dalam bahasa Indonesia yang natural dan membantu."
val result = generativeModel.generateContent(fullPrompt)
val response = result.text ?: "Tidak ada respons dari AI"
chatMessages = chatMessages + ChatMessage(
message = response,
isUser = false
)
saveChatHistory()
} catch (e: Exception) {
errorMessage = when {
e.message?.contains("quota", ignoreCase = true) == true ->
"⚠️ Kuota API habis. Silakan coba lagi nanti atau hubungi developer."
e.message?.contains("404", ignoreCase = true) == true ||
e.message?.contains("not found", ignoreCase = true) == true ->
"⚠️ Model AI tidak ditemukan. Silakan hubungi developer."
e.message?.contains("401", ignoreCase = true) == true ||
e.message?.contains("API key", ignoreCase = true) == true ->
"⚠️ API key tidak valid. Silakan hubungi developer."
e.message?.contains("timeout", ignoreCase = true) == true ->
"⚠️ Koneksi timeout. Periksa koneksi internet Anda."
e.message?.contains("network", ignoreCase = true) == true ->
"⚠️ Tidak ada koneksi internet. Silakan periksa koneksi Anda."
else ->
"⚠️ Terjadi kesalahan: ${e.message?.take(100) ?: "Unknown error"}"
}
} finally {
isLoading = false
}
}
}
},
containerColor = AppColors.Primary,
modifier = Modifier.size(48.dp)
) {
Icon(
Icons.Default.Send,
contentDescription = "Send",
tint = Color.White,
modifier = Modifier.size(20.dp)
)
}
}
}
}
}

View File

@ -0,0 +1,206 @@
package com.example.notesai.presentation.screens.ai.components
import android.net.Uri
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.*
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.example.notesai.util.AppColors
import com.example.notesai.util.Constants
import com.example.notesai.util.FileParser
import com.example.notesai.util.FileParseResult
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@Composable
fun FileUploadButton(
onFileSelected: (FileParseResult.Success) -> Unit,
onError: (String) -> Unit,
modifier: Modifier = Modifier
) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
var isProcessing by remember { mutableStateOf(false) }
var selectedFileName by remember { mutableStateOf<String?>(null) }
// File picker launcher
val filePicker = rememberLauncherForActivityResult(
contract = ActivityResultContracts.GetContent()
) { uri: Uri? ->
uri?.let {
scope.launch {
isProcessing = true
try {
// Get file info
val fileSize = FileParser.getFileSize(context, uri)
// Check file size (max 10MB)
if (fileSize > 10 * 1024 * 1024) {
onError("File terlalu besar. Maksimal 10MB")
isProcessing = false
return@launch
}
// Parse file
val result = withContext(Dispatchers.IO) {
FileParser.parseFile(context, uri)
}
when (result) {
is FileParseResult.Success -> {
selectedFileName = result.fileName
onFileSelected(result)
}
is FileParseResult.Error -> {
onError(result.message)
}
}
} catch (e: Exception) {
onError("Gagal memproses file: ${e.message}")
} finally {
isProcessing = false
}
}
}
}
Button(
onClick = {
// Launch file picker untuk PDF, TXT, DOCX
filePicker.launch("*/*")
},
enabled = !isProcessing,
colors = ButtonDefaults.buttonColors(
containerColor = AppColors.Secondary.copy(alpha = 0.15f),
contentColor = AppColors.Secondary,
disabledContainerColor = AppColors.SurfaceVariant,
disabledContentColor = AppColors.OnSurfaceVariant
),
shape = RoundedCornerShape(Constants.Radius.Medium.dp),
modifier = modifier,
contentPadding = PaddingValues(
horizontal = Constants.Spacing.Medium.dp,
vertical = Constants.Spacing.Small.dp
)
) {
if (isProcessing) {
CircularProgressIndicator(
modifier = Modifier.size(20.dp),
color = AppColors.Secondary,
strokeWidth = 2.dp
)
Spacer(modifier = Modifier.width(8.dp))
Text("Memproses...")
} else {
Icon(
Icons.Default.AttachFile,
contentDescription = "Upload File",
modifier = Modifier.size(20.dp)
)
Spacer(modifier = Modifier.width(8.dp))
Text(
"Upload File",
fontSize = 14.sp,
fontWeight = FontWeight.SemiBold
)
}
}
}
@Composable
fun FilePreviewCard(
fileResult: FileParseResult.Success,
onDismiss: () -> Unit,
modifier: Modifier = Modifier
) {
Surface(
modifier = modifier,
color = AppColors.SurfaceVariant,
shape = RoundedCornerShape(Constants.Radius.Large.dp),
shadowElevation = 4.dp
) {
Column(
modifier = Modifier.padding(16.dp)
) {
// Header
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
when (fileResult.fileType) {
"PDF" -> Icons.Default.PictureAsPdf
"Word" -> Icons.Default.Description
else -> Icons.Default.TextSnippet
},
contentDescription = null,
tint = AppColors.Secondary,
modifier = Modifier.size(24.dp)
)
Column {
Text(
fileResult.fileName,
fontWeight = FontWeight.Bold,
fontSize = 14.sp,
color = AppColors.OnSurface
)
Text(
"${fileResult.fileType}${fileResult.wordCount} kata",
fontSize = 12.sp,
color = AppColors.OnSurfaceTertiary
)
}
}
IconButton(
onClick = onDismiss,
modifier = Modifier.size(32.dp)
) {
Icon(
Icons.Default.Close,
contentDescription = "Close",
tint = AppColors.OnSurfaceVariant
)
}
}
Spacer(modifier = Modifier.height(12.dp))
// Content Preview
Surface(
color = AppColors.Surface,
shape = RoundedCornerShape(8.dp)
) {
Text(
fileResult.content.take(200) + if (fileResult.content.length > 200) "..." else "",
modifier = Modifier.padding(12.dp),
fontSize = 12.sp,
color = AppColors.OnSurfaceVariant,
lineHeight = 18.sp
)
}
}
}
}

View File

@ -0,0 +1,221 @@
package com.example.notesai.util
import android.content.Context
import android.net.Uri
import android.util.Log
import com.tom_roush.pdfbox.android.PDFBoxResourceLoader
import com.tom_roush.pdfbox.pdmodel.PDDocument
import com.tom_roush.pdfbox.text.PDFTextStripper
import java.io.BufferedReader
import java.io.InputStreamReader
object FileParser {
private const val TAG = "FileParser"
/**
* Initialize PDFBox (call this in Application.onCreate or before first use)
*/
fun initPDFBox(context: Context) {
try {
PDFBoxResourceLoader.init(context)
Log.d(TAG, "PDFBox initialized successfully")
} catch (e: Exception) {
Log.e(TAG, "Failed to initialize PDFBox", e)
}
}
/**
* Parse file berdasarkan tipe
*/
suspend fun parseFile(context: Context, uri: Uri): FileParseResult {
return try {
val mimeType = context.contentResolver.getType(uri)
val fileName = getFileName(context, uri)
Log.d(TAG, "Parsing file: $fileName, type: $mimeType")
val content = when {
mimeType == "application/pdf" || fileName.endsWith(".pdf", ignoreCase = true) -> {
parsePDF(context, uri)
}
mimeType == "text/plain" || fileName.endsWith(".txt", ignoreCase = true) -> {
parseTXT(context, uri)
}
mimeType == "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
|| fileName.endsWith(".docx", ignoreCase = true) -> {
parseDOCX(context, uri)
}
else -> {
return FileParseResult.Error("Format file tidak didukung: $mimeType")
}
}
if (content.isBlank()) {
FileParseResult.Error("File kosong atau tidak dapat dibaca")
} else {
FileParseResult.Success(
content = content,
fileName = fileName,
fileType = getFileType(fileName),
wordCount = content.split(Regex("\\s+")).size
)
}
} catch (e: Exception) {
Log.e(TAG, "Error parsing file", e)
FileParseResult.Error("Gagal membaca file: ${e.message}")
}
}
/**
* Parse PDF file
*/
private fun parsePDF(context: Context, uri: Uri): String {
val inputStream = context.contentResolver.openInputStream(uri)
?: throw Exception("Cannot open file")
return inputStream.use { stream ->
val document = PDDocument.load(stream)
val stripper = PDFTextStripper()
val text = stripper.getText(document)
document.close()
text.trim()
}
}
/**
* Parse TXT file
*/
private fun parseTXT(context: Context, uri: Uri): String {
val inputStream = context.contentResolver.openInputStream(uri)
?: throw Exception("Cannot open file")
return inputStream.use { stream ->
BufferedReader(InputStreamReader(stream, Charsets.UTF_8))
.readText()
.trim()
}
}
/**
* Parse DOCX file - SIMPLIFIED VERSION
* Hanya extract text mentah dari XML
*/
private fun parseDOCX(context: Context, uri: Uri): String {
val inputStream = context.contentResolver.openInputStream(uri)
?: throw Exception("Cannot open file")
return inputStream.use { stream ->
try {
// DOCX adalah ZIP file, kita extract document.xml
val zipInputStream = java.util.zip.ZipInputStream(stream)
val text = StringBuilder()
var entry = zipInputStream.nextEntry
while (entry != null) {
if (entry.name == "word/document.xml") {
val xmlContent = zipInputStream.bufferedReader().readText()
// Extract text dari XML tags
// Format: <w:t>text here</w:t>
val textPattern = Regex("<w:t[^>]*>([^<]+)</w:t>")
textPattern.findAll(xmlContent).forEach { match ->
text.append(match.groupValues[1])
text.append(" ")
}
// Extract text dari paragraph tags
val paraPattern = Regex("<w:p[^>]*>(.*?)</w:p>", RegexOption.DOT_MATCHES_ALL)
paraPattern.findAll(xmlContent).forEach { match ->
val paraContent = match.groupValues[1]
val textInPara = Regex("<w:t[^>]*>([^<]+)</w:t>")
textInPara.findAll(paraContent).forEach { textMatch ->
text.append(textMatch.groupValues[1])
text.append(" ")
}
text.append("\n")
}
break
}
entry = zipInputStream.nextEntry
}
zipInputStream.close()
text.toString().trim()
} catch (e: Exception) {
Log.e(TAG, "Error parsing DOCX", e)
throw Exception("Gagal membaca file DOCX: ${e.message}")
}
}
}
/**
* Get file name from URI
*/
private fun getFileName(context: Context, uri: Uri): String {
var fileName = "unknown"
context.contentResolver.query(uri, null, null, null, null)?.use { cursor ->
val nameIndex = cursor.getColumnIndex(android.provider.OpenableColumns.DISPLAY_NAME)
if (cursor.moveToFirst() && nameIndex != -1) {
fileName = cursor.getString(nameIndex)
}
}
return fileName
}
/**
* Get file type display name
*/
private fun getFileType(fileName: String): String {
return when {
fileName.endsWith(".pdf", ignoreCase = true) -> "PDF"
fileName.endsWith(".txt", ignoreCase = true) -> "Text"
fileName.endsWith(".docx", ignoreCase = true) -> "Word"
else -> "Unknown"
}
}
/**
* Get file size
*/
fun getFileSize(context: Context, uri: Uri): Long {
var size = 0L
context.contentResolver.query(uri, null, null, null, null)?.use { cursor ->
val sizeIndex = cursor.getColumnIndex(android.provider.OpenableColumns.SIZE)
if (cursor.moveToFirst() && sizeIndex != -1) {
size = cursor.getLong(sizeIndex)
}
}
return size
}
/**
* Format file size untuk display
*/
fun formatFileSize(bytes: Long): String {
return when {
bytes < 1024 -> "$bytes B"
bytes < 1024 * 1024 -> "${bytes / 1024} KB"
else -> "${bytes / (1024 * 1024)} MB"
}
}
}
/**
* Result dari parsing file
*/
sealed class FileParseResult {
data class Success(
val content: String,
val fileName: String,
val fileType: String,
val wordCount: Int
) : FileParseResult()
data class Error(val message: String) : FileParseResult()
}