diff --git a/.github/scripts/prepare.sh b/.github/scripts/prepare.sh new file mode 100644 index 0000000..aaba894 --- /dev/null +++ b/.github/scripts/prepare.sh @@ -0,0 +1,41 @@ +#!/usr/bin/env bash +# https://github.com/django-files/android-client + +set -e + +HLJS_VERSION="11.11.1" + +which git || (echo "Missing: git" && exit 1) +which npm || (echo "Missing: npm" && exit 1) +which node || (echo "Missing: node" && exit 1) + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +echo "SCRIPT_DIR: ${SCRIPT_DIR}" + +echo "Processing highlight.js: ${HLJS_VERSION}" + +SOURCE_DIR="$(realpath "${SCRIPT_DIR}/../highlightjs")" +echo "SOURCE_DIR: ${SOURCE_DIR}" +if [ -d "${SOURCE_DIR}" ];then + echo "Removing: ${SOURCE_DIR}" + rm -rf "${SOURCE_DIR}" +fi + +git clone https://github.com/highlightjs/highlight.js.git "${SOURCE_DIR}" +cd "${SOURCE_DIR}" +git checkout "${HLJS_VERSION}" + +npm install +node tools/build.js :common + +TARGET_DIR="$(realpath "${SCRIPT_DIR}/../../app/src/main/assets/preview/dist")" +echo "TARGET_DIR: ${TARGET_DIR}" +mkdir -p "${TARGET_DIR}" + +cp build/highlight.min.js "${TARGET_DIR}" +cp src/styles/stackoverflow-dark.css "${TARGET_DIR}" +cp src/styles/stackoverflow-light.css "${TARGET_DIR}" + +echo "Finished: highlight.js" + +echo "Everything is finished." diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 2485a5a..7792f9a 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -15,7 +15,7 @@ env: name: zipline.apk # Final APK Name key_name: zipline-release key_file: release.keystore - tools_path: /usr/local/lib/android/sdk/build-tools/35.0.0 + tools_path: /usr/local/lib/android/sdk/build-tools/36.0.0 cmdline_tools: /usr/local/lib/android/sdk/cmdline-tools/latest/bin jobs: @@ -81,6 +81,16 @@ jobs: cat app/build.gradle.kts echo "::endgroup::" + - name: "Setup Node 22" + uses: actions/setup-node@v4 + with: + node-version: 22 + + - name: "Prepare Build" + working-directory: ".github/scripts" + run: | + bash prepare.sh + - name: "Setup Java" uses: actions/setup-java@v4 with: diff --git a/.gitignore b/.gitignore index e59c676..8e9cd8b 100644 --- a/.gitignore +++ b/.gitignore @@ -7,8 +7,11 @@ .gradle/ .kotlin/ captures/ +app/debug app/release local.properties *.keystore *.logcat + +**/dist/ diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..ce80d28 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,10 @@ +# IDE +.idea/ +.vscode/ + +# Build +**/dist/ +**/node_modules/ + +# App +app/src/main/assets/preview/** diff --git a/README.md b/README.md index 36d608d..150e5d6 100644 --- a/README.md +++ b/README.md @@ -82,7 +82,7 @@ Additionally, the URL is copied to the clipboard and the preview is show in the ### Planned -- Sharing multiple files at once. +- Improve [Django Files](https://github.com/django-files). ### Known Issues diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 6ec971a..73f30ce 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,16 +1,17 @@ plugins { alias(libs.plugins.android.application) alias(libs.plugins.kotlin.android) + //alias(libs.plugins.ksp) } android { namespace = "org.cssnr.zipline" - compileSdk = 35 + compileSdk = 36 defaultConfig { applicationId = "org.cssnr.zipline" minSdk = 26 - targetSdk = 35 + targetSdk = 36 versionCode = 1 versionName = "0.0.1" @@ -20,13 +21,19 @@ android { buildTypes { release { isMinifyEnabled = true + isShrinkResources = true proguardFiles( getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro" ) - isDebuggable = false + } + + debug { + applicationIdSuffix = ".dev" + versionNameSuffix = "-dev" } } + compileOptions { sourceCompatibility = JavaVersion.VERSION_11 targetCompatibility = JavaVersion.VERSION_11 @@ -52,6 +59,11 @@ dependencies { implementation(libs.okhttp) implementation(libs.retrofit) implementation(libs.retrofit.gson) + implementation(libs.glide) + implementation(libs.media3.exoplayer) + implementation(libs.media3.exoplayer.dash) + implementation(libs.media3.ui) + implementation(libs.media3.ui.compose) testImplementation(libs.junit) androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.espresso.core) diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index fc56dbd..6364f6e 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -85,3 +85,20 @@ # With R8 full mode generic signatures are stripped for classes that are not kept. -keep,allowobfuscation,allowshrinking class retrofit2.Response + + +# https://github.com/bumptech/glide/blob/master/library/proguard-rules.txt +-keep public class * implements com.bumptech.glide.module.GlideModule +-keep class * extends com.bumptech.glide.module.AppGlideModule { + (...); +} +-keep public enum com.bumptech.glide.load.ImageHeaderParser$** { + **[] $VALUES; + public *; +} +-keep class com.bumptech.glide.load.data.ParcelFileDescriptorRewinder$InternalRewinder { + *** rewind(); +} + +# Uncomment for DexGuard only +#-keepresourcexmlelements manifest/application/meta-data@value=GlideModule diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index ac46ad4..354cef7 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -15,12 +15,12 @@ android:theme="@style/Theme.Zipline" android:enableOnBackInvokedCallback="true" android:usesCleartextTraffic="true" - tools:targetApi="35"> + tools:targetApi="36"> + + + + + Preview + + + + + +

+    
+    
+
+
diff --git a/app/src/main/assets/preview/preview.js b/app/src/main/assets/preview/preview.js
new file mode 100644
index 0000000..6ca1fe8
--- /dev/null
+++ b/app/src/main/assets/preview/preview.js
@@ -0,0 +1,31 @@
+const preEl = document.querySelector('pre')
+const darkStyle = document.getElementById('code-dark')
+const lightStyle = document.getElementById('code-light')
+
+document.addEventListener('DOMContentLoaded', () => {
+    console.log('DOMContentLoaded')
+    const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
+    applyTheme(mediaQuery)
+    mediaQuery.addEventListener('change', applyTheme)
+
+    if (typeof Android !== 'undefined') {
+        console.log('Android')
+        Android.notifyReady()
+    }
+})
+
+function applyTheme(mediaQuery) {
+    console.log(`applyTheme: matches: ${mediaQuery.matches}`)
+    if (mediaQuery.matches) {
+        darkStyle.disabled = false
+        lightStyle.disabled = true
+    } else {
+        darkStyle.disabled = true
+        lightStyle.disabled = false
+    }
+}
+
+function addContent(content) {
+    preEl.textContent = content
+    hljs.highlightElement(preEl)
+}
diff --git a/app/src/main/java/org/cssnr/zipline/MainActivity.kt b/app/src/main/java/org/cssnr/zipline/MainActivity.kt
index 4156eae..b7300b1 100644
--- a/app/src/main/java/org/cssnr/zipline/MainActivity.kt
+++ b/app/src/main/java/org/cssnr/zipline/MainActivity.kt
@@ -1,6 +1,10 @@
 package org.cssnr.zipline
 
 import android.annotation.SuppressLint
+import android.content.ClipData
+import android.content.ClipboardManager
+import android.content.Context
+import android.content.Context.CLIPBOARD_SERVICE
 import android.content.Intent
 import android.graphics.Color
 import android.net.Uri
@@ -27,7 +31,6 @@ class MainActivity : AppCompatActivity() {
     private lateinit var navController: NavController
     private lateinit var filePickerLauncher: ActivityResultLauncher>
 
-
     @SuppressLint("SetJavaScriptEnabled", "SetTextI18n")
     override fun onCreate(savedInstanceState: Bundle?) {
         super.onCreate(savedInstanceState)
@@ -68,34 +71,38 @@ class MainActivity : AppCompatActivity() {
         }
 
         filePickerLauncher =
-            registerForActivityResult(ActivityResultContracts.OpenDocument()) { uri ->
-                Log.d("filePickerLauncher", "uri: $uri")
-                if (uri != null) {
-                    val mimeType = contentResolver.getType(uri)
-                    Log.d("filePickerLauncher", "mimeType: $mimeType")
-                    showPreview(uri, mimeType)
+            registerForActivityResult(ActivityResultContracts.OpenMultipleDocuments()) { uris ->
+                Log.d("filePickerLauncher", "uris: $uris")
+                if (uris.size > 1) {
+                    Log.i("filePickerLauncher", "MULTI!")
+                    showMultiPreview(uris as ArrayList)
+                } else if (uris.size == 1) {
+                    Log.i("filePickerLauncher", "SINGLE!")
+                    showPreview(uris[0])
                 } else {
-                    Log.w("filePickerLauncher", "No File Selected!")
-                    Toast.makeText(this, "No File Selected!", Toast.LENGTH_SHORT).show()
+                    Log.w("filePickerLauncher", "No Files Selected!")
+                    Toast.makeText(this, "No Files Selected!", Toast.LENGTH_SHORT).show()
                 }
             }
 
-        handleIntent(intent, savedInstanceState)
+        // Only Handel Intent Once Here after App Start
+        if (savedInstanceState?.getBoolean("intentHandled") != true) {
+            handleIntent(intent)
+        }
     }
 
-    fun setDrawerLockMode(enabled: Boolean) {
-        val lockMode =
-            if (enabled) DrawerLayout.LOCK_MODE_UNLOCKED else DrawerLayout.LOCK_MODE_LOCKED_CLOSED
-        binding.drawerLayout.setDrawerLockMode(lockMode)
+    override fun onSaveInstanceState(outState: Bundle) {
+        super.onSaveInstanceState(outState)
+        outState.putBoolean("intentHandled", true)
     }
 
     override fun onNewIntent(intent: Intent) {
         super.onNewIntent(intent)
         Log.d("onNewIntent", "intent: $intent")
-        handleIntent(intent, null)
+        handleIntent(intent)
     }
 
-    private fun handleIntent(intent: Intent, savedInstanceState: Bundle?) {
+    private fun handleIntent(intent: Intent) {
         Log.d("handleIntent", "intent: $intent")
         Log.d("handleIntent", "intent.data: ${intent.data}")
         Log.d("handleIntent", "intent.type: ${intent.type}")
@@ -120,7 +127,7 @@ class MainActivity : AppCompatActivity() {
             )
 
         } else if (Intent.ACTION_MAIN == intent.action) {
-            Log.d("handleIntent", "ACTION_MAIN: ${savedInstanceState?.size()}")
+            Log.d("handleIntent", "ACTION_MAIN")
 
             binding.drawerLayout.closeDrawers()
 
@@ -131,10 +138,10 @@ class MainActivity : AppCompatActivity() {
             Log.d("handleIntent", "launcherAction: $launcherAction")
             val fromShortcut = intent.getStringExtra("fromShortcut")
             Log.d("handleIntent", "fromShortcut: $fromShortcut")
-            Log.d("handleIntent", "nav_item_preview: ${R.id.nav_item_preview}")
+            Log.d("handleIntent", "nav_item_preview: ${R.id.nav_item_upload}")
             Log.d("handleIntent", "nav_item_short: ${R.id.nav_item_short}")
 
-            if (currentDestinationId == R.id.nav_item_preview || currentDestinationId == R.id.nav_item_short) {
+            if (currentDestinationId == R.id.nav_item_upload || currentDestinationId == R.id.nav_item_short) {
                 Log.i("handleIntent", "ON PREVIEW/SHORT - Navigating to HomeFragment w/ setPopUpTo")
                 // TODO: Determine the correct navigation call here...
                 //navController.navigate(R.id.nav_item_home)
@@ -187,7 +194,7 @@ class MainActivity : AppCompatActivity() {
                     Log.w("handleIntent", "NOT IMPLEMENTED")
                 }
             } else {
-                showPreview(fileUri, intent.type)
+                showPreview(fileUri)
             }
 
         } else if (Intent.ACTION_SEND_MULTIPLE == intent.action) {
@@ -201,21 +208,17 @@ class MainActivity : AppCompatActivity() {
             }
             Log.d("handleIntent", "fileUris: $fileUris")
             if (fileUris == null) {
-                Toast.makeText(this, "Error Parsing URI!", Toast.LENGTH_SHORT).show()
+                Toast.makeText(this, "Error Parsing URI!", Toast.LENGTH_LONG).show()
                 Log.w("handleIntent", "fileUris is null")
                 return
             }
-            for (fileUri in fileUris) {
-                Log.d("handleIntent", "MULTI: fileUri: $fileUri")
-            }
-            Toast.makeText(this, "Not Yet Implemented!", Toast.LENGTH_SHORT).show()
-            Log.w("handleIntent", "NOT IMPLEMENTED")
+            showMultiPreview(fileUris)
 
         } else if (Intent.ACTION_VIEW == intent.action) {
             Log.d("handleIntent", "ACTION_VIEW")
 
             Log.d("handleIntent", "File URI: ${intent.data}")
-            showPreview(intent.data, intent.type)
+            showPreview(intent.data)
 
         } else {
             Toast.makeText(this, "That's a Bug!", Toast.LENGTH_SHORT).show()
@@ -234,20 +237,47 @@ class MainActivity : AppCompatActivity() {
         }
     }
 
-    private fun showPreview(uri: Uri?, type: String?) {
-        Log.d("Main[showPreview]", "$type - $uri")
+    private fun showPreview(uri: Uri?) {
+        Log.d("Main[showPreview]", "uri: $uri")
         val bundle = Bundle().apply {
             putString("uri", uri.toString())
-            putString("type", type)
         }
         binding.drawerLayout.closeDrawers()
         // TODO: This destroys the home fragment making restore from state impossible
         navController.popBackStack(R.id.nav_graph, true)
         navController.navigate(
-            R.id.nav_item_preview, bundle, NavOptions.Builder()
+            R.id.nav_item_upload, bundle, NavOptions.Builder()
+                .setPopUpTo(R.id.nav_item_home, true)
+                .setLaunchSingleTop(true)
+                .build()
+        )
+    }
+
+    private fun showMultiPreview(fileUris: ArrayList) {
+        Log.d("Main[showMultiPreview]", "fileUris: $fileUris")
+        //fileUris.sort()
+        binding.drawerLayout.closeDrawers()
+        val bundle = Bundle().apply { putParcelableArrayList("fileUris", fileUris) }
+        navController.popBackStack(R.id.nav_graph, true)
+        navController.navigate(
+            R.id.nav_item_upload_multi, bundle, NavOptions.Builder()
                 .setPopUpTo(R.id.nav_item_home, true)
                 .setLaunchSingleTop(true)
                 .build()
         )
     }
+
+    fun setDrawerLockMode(enabled: Boolean) {
+        val lockMode =
+            if (enabled) DrawerLayout.LOCK_MODE_UNLOCKED else DrawerLayout.LOCK_MODE_LOCKED_CLOSED
+        binding.drawerLayout.setDrawerLockMode(lockMode)
+    }
+}
+
+fun copyToClipboard(context: Context, url: String) {
+    Log.d("copyToClipboard", "url: $url")
+    val clipboard = context.getSystemService(CLIPBOARD_SERVICE) as ClipboardManager
+    val clip = ClipData.newPlainText("URL", url)
+    clipboard.setPrimaryClip(clip)
+    Toast.makeText(context, "Copied URL to Clipboard.", Toast.LENGTH_SHORT).show()
 }
diff --git a/app/src/main/java/org/cssnr/zipline/PreviewFragment.kt b/app/src/main/java/org/cssnr/zipline/PreviewFragment.kt
deleted file mode 100644
index b8f1e6c..0000000
--- a/app/src/main/java/org/cssnr/zipline/PreviewFragment.kt
+++ /dev/null
@@ -1,205 +0,0 @@
-package org.cssnr.zipline
-
-import android.content.ClipData
-import android.content.ClipboardManager
-import android.content.Context.CLIPBOARD_SERVICE
-import android.content.Context.MODE_PRIVATE
-import android.content.Intent
-import android.graphics.PorterDuff
-import android.net.Uri
-import android.os.Bundle
-import android.util.Log
-import android.util.TypedValue
-import android.view.LayoutInflater
-import android.view.View
-import android.view.ViewGroup
-import android.widget.Toast
-import androidx.core.content.ContextCompat
-import androidx.core.graphics.ColorUtils
-import androidx.core.net.toUri
-import androidx.core.os.bundleOf
-import androidx.fragment.app.Fragment
-import androidx.lifecycle.lifecycleScope
-import androidx.navigation.NavController
-import androidx.navigation.NavOptions
-import androidx.navigation.fragment.findNavController
-import com.google.android.material.shape.CornerFamily
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.launch
-import kotlinx.coroutines.withContext
-import org.cssnr.zipline.databinding.FragmentPreviewBinding
-
-class PreviewFragment : Fragment() {
-
-    private var _binding: FragmentPreviewBinding? = null
-    private val binding get() = _binding!!
-
-    private lateinit var navController: NavController
-
-    override fun onCreateView(
-        inflater: LayoutInflater,
-        container: ViewGroup?,
-        savedInstanceState: Bundle?
-    ): View {
-        Log.d("PreviewFragment", "onCreateView: $savedInstanceState")
-        _binding = FragmentPreviewBinding.inflate(inflater, container, false)
-        val root: View = binding.root
-        return root
-    }
-
-    override fun onDestroyView() {
-        Log.d("PreviewFragment", "onDestroyView")
-        super.onDestroyView()
-        _binding = null
-    }
-
-    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
-        Log.d("onViewCreated", "savedInstanceState: $savedInstanceState")
-        Log.d("onViewCreated", "arguments: $arguments")
-
-        navController = findNavController()
-
-        //val uri = arguments?.getString("uri")?.toUri()
-        val uri = requireArguments().getString("uri")?.toUri()
-        Log.d("onViewCreated", "uri: $uri")
-
-        val type = arguments?.getString("type")
-        Log.d("onViewCreated", "type: $type")
-
-        //val text = arguments?.getString("text")
-        //Log.d("onViewCreated", "text: $text")
-
-        if (uri == null) {
-            // TODO: Better Handle this Error
-            Log.e("onViewCreated", "URI is null")
-            Toast.makeText(requireContext(), "No URI to Process!", Toast.LENGTH_LONG).show()
-            return
-        }
-
-        val fileName = getFileNameFromUri(requireContext(), uri)
-        Log.d("onViewCreated", "fileName: $fileName")
-        //binding.fileName.text = fileName
-        binding.fileName.setText(fileName)
-
-        if (type?.startsWith("image/") == true) {
-            // Show Image Preview
-            binding.imagePreview.setImageURI(uri)
-        } else {
-            // Set Tint of Icon
-            val typedValue = TypedValue()
-            val theme = binding.imagePreview.context.theme
-            theme.resolveAttribute(android.R.attr.textColorPrimary, typedValue, true)
-            val tint = ContextCompat.getColor(binding.imagePreview.context, typedValue.resourceId)
-            val dimmedTint = ColorUtils.setAlphaComponent(tint, (0.5f * 255).toInt())
-            binding.imagePreview.setColorFilter(dimmedTint, PorterDuff.Mode.SRC_IN)
-            // Set Mime Type Text
-            binding.imageOverlayText.text = type
-            binding.imageOverlayText.visibility = View.VISIBLE
-            // Set Icon Based on Type
-            // TODO: Create Mapping...
-            if (type?.startsWith("text/") == true) {
-                binding.imagePreview.setImageResource(R.drawable.baseline_text_snippet_24)
-            } else if (type?.startsWith("video/") == true) {
-                binding.imagePreview.setImageResource(R.drawable.baseline_video_file_24)
-            } else if (type?.startsWith("audio/") == true) {
-                binding.imagePreview.setImageResource(R.drawable.baseline_audio_file_24)
-            } else {
-                binding.imagePreview.setImageResource(R.drawable.baseline_insert_drive_file_24)
-            }
-        }
-
-        val radius = resources.getDimension(R.dimen.image_radius)
-        binding.imagePreview.setShapeAppearanceModel(
-            binding.imagePreview.shapeAppearanceModel
-                .toBuilder()
-                .setAllCorners(CornerFamily.ROUNDED, radius)
-                .build()
-        )
-
-        val sharedPreferences = context?.getSharedPreferences("default_preferences", MODE_PRIVATE)
-        val ziplineUrl = sharedPreferences?.getString("ziplineUrl", null)
-        val ziplineToken = sharedPreferences?.getString("ziplineToken", null)
-        Log.d("onViewCreated", "ziplineUrl: $ziplineUrl")
-        Log.d("onViewCreated", "ziplineToken: $ziplineToken")
-
-        if (ziplineUrl == null || ziplineToken == null) {
-            Log.e("onViewCreated", "ziplineUrl || ziplineToken is null")
-            Toast.makeText(requireContext(), "Missing Zipline Authentication!", Toast.LENGTH_LONG)
-                .show()
-            navController.navigate(
-                R.id.nav_item_setup, null, NavOptions.Builder()
-                    .setPopUpTo(R.id.nav_item_home, true)
-                    .build()
-            )
-            return
-        }
-
-        binding.shareButton.setOnClickListener {
-            Log.d("shareButton", "setOnClickListener")
-            val shareIntent = Intent(Intent.ACTION_SEND).apply {
-                this.type = type
-                putExtra(Intent.EXTRA_STREAM, uri)
-                addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
-            }
-            startActivity(Intent.createChooser(shareIntent, null))
-        }
-
-        binding.optionsButton.setOnClickListener {
-            Log.d("optionsButton", "setOnClickListener")
-            navController.navigate(R.id.nav_item_settings)
-        }
-
-        binding.openButton.setOnClickListener {
-            Log.d("openButton", "setOnClickListener")
-            val openIntent = Intent(Intent.ACTION_VIEW).apply {
-                setDataAndType(uri, type)
-                addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
-            }
-            startActivity(Intent.createChooser(openIntent, null))
-        }
-
-        binding.uploadButton.setOnClickListener {
-            val fileName = binding.fileName.text.toString().trim()
-            Log.d("uploadButton", "fileName: $fileName")
-            processUpload(uri, fileName, ziplineUrl)
-        }
-    }
-
-    private fun processUpload(uri: Uri, fileName: String, ziplineUrl: String) {
-        // TODO: Cleanup to work with multiple files...
-        Log.d("processUpload", "File URI: $uri")
-        val api = ZiplineApi(requireContext())
-        lifecycleScope.launch {
-            val response = api.upload(uri, fileName, ziplineUrl)
-            Log.d("processUpload", "response: $response")
-            val result = response?.files?.firstOrNull()
-            Log.d("processUpload", "result: $result")
-            if (result != null) {
-                Log.d("processUpload", "result.url: ${result.url}")
-                copyToClipboard(result.url)
-                navController.navigate(
-                    R.id.nav_item_home,
-                    bundleOf("url" to result.url),
-                    NavOptions.Builder()
-                        .setPopUpTo(R.id.nav_graph, inclusive = true)
-                        .build()
-                )
-                Log.d("processUpload", "DONE")
-            } else {
-                Log.e("processUpload", "uploadedFile is null")
-                withContext(Dispatchers.Main) {
-                    Toast.makeText(requireContext(), "File Upload Failed!", Toast.LENGTH_LONG)
-                        .show()
-                }
-            }
-        }
-    }
-
-    private fun copyToClipboard(url: String) {
-        Log.d("copyToClipboard", "url: $url")
-        val clipboard = requireContext().getSystemService(CLIPBOARD_SERVICE) as ClipboardManager
-        val clip = ClipData.newPlainText("URL", url)
-        clipboard.setPrimaryClip(clip)
-        Toast.makeText(requireContext(), "Copied URL to Clipboard.", Toast.LENGTH_SHORT).show()
-    }
-}
diff --git a/app/src/main/java/org/cssnr/zipline/SettingsFragment.kt b/app/src/main/java/org/cssnr/zipline/SettingsFragment.kt
index 22106b7..82be10e 100644
--- a/app/src/main/java/org/cssnr/zipline/SettingsFragment.kt
+++ b/app/src/main/java/org/cssnr/zipline/SettingsFragment.kt
@@ -11,6 +11,7 @@ import androidx.preference.SwitchPreferenceCompat
 import com.google.android.material.color.MaterialColors
 
 class SettingsFragment : PreferenceFragmentCompat() {
+
     override fun onCreateView(
         inflater: LayoutInflater,
         container: ViewGroup?,
diff --git a/app/src/main/java/org/cssnr/zipline/SetupFragment.kt b/app/src/main/java/org/cssnr/zipline/SetupFragment.kt
index 756a05c..25c6971 100644
--- a/app/src/main/java/org/cssnr/zipline/SetupFragment.kt
+++ b/app/src/main/java/org/cssnr/zipline/SetupFragment.kt
@@ -109,9 +109,11 @@ class SetupFragment : Fragment() {
                     Log.d("getSharedPreferences", "ziplineUrl: $host")
                     sharedPreferences?.edit { putString("ziplineToken", token) }
                     Log.d("getSharedPreferences", "ziplineToken: $token")
-                    findNavController().navigate(R.id.nav_item_home, null, NavOptions.Builder()
-                        .setPopUpTo(R.id.nav_item_setup, true)
-                        .build())
+                    findNavController().navigate(
+                        R.id.nav_item_home, null, NavOptions.Builder()
+                            .setPopUpTo(R.id.nav_item_setup, true)
+                            .build()
+                    )
                 }
             }
             Log.d("setOnClickListener", "DONE")
diff --git a/app/src/main/java/org/cssnr/zipline/ShortFragment.kt b/app/src/main/java/org/cssnr/zipline/ShortFragment.kt
deleted file mode 100644
index 85754d6..0000000
--- a/app/src/main/java/org/cssnr/zipline/ShortFragment.kt
+++ /dev/null
@@ -1,180 +0,0 @@
-package org.cssnr.zipline
-
-import android.content.ClipData
-import android.content.ClipboardManager
-import android.content.Context.CLIPBOARD_SERVICE
-import android.content.Context.MODE_PRIVATE
-import android.content.Intent
-import android.os.Bundle
-import android.util.Log
-import android.view.LayoutInflater
-import android.view.View
-import android.view.ViewGroup
-import android.widget.Toast
-import androidx.core.net.toUri
-import androidx.core.os.bundleOf
-import androidx.fragment.app.Fragment
-import androidx.lifecycle.lifecycleScope
-import androidx.navigation.NavController
-import androidx.navigation.NavOptions
-import androidx.navigation.fragment.findNavController
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.launch
-import kotlinx.coroutines.withContext
-import org.cssnr.zipline.databinding.FragmentShortBinding
-
-class ShortFragment : Fragment() {
-
-    private var _binding: FragmentShortBinding? = null
-    private val binding get() = _binding!!
-
-    private lateinit var navController: NavController
-
-    override fun onCreateView(
-        inflater: LayoutInflater,
-        container: ViewGroup?,
-        savedInstanceState: Bundle?
-    ): View {
-        Log.d("ShortFragment", "onCreateView: $savedInstanceState")
-        _binding = FragmentShortBinding.inflate(inflater, container, false)
-        val root: View = binding.root
-        return root
-    }
-
-    override fun onDestroyView() {
-        Log.d("ShortFragment", "onDestroyView")
-        super.onDestroyView()
-        _binding = null
-    }
-
-    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
-        Log.d("Short[onViewCreated]", "savedInstanceState: $savedInstanceState")
-        Log.d("Short[onViewCreated]", "arguments: $arguments")
-
-        navController = findNavController()
-
-        val url = requireArguments().getString("url")
-        Log.d("Short[onViewCreated]", "url: $url")
-
-        if (url == null) {
-            // TODO: Better Handle this Error
-            Log.e("Short[onViewCreated]", "URL is null")
-            Toast.makeText(requireContext(), "No URL to Process!", Toast.LENGTH_LONG).show()
-            return
-        }
-
-        val sharedPreferences = context?.getSharedPreferences("default_preferences", MODE_PRIVATE)
-        val ziplineUrl = sharedPreferences?.getString("ziplineUrl", null)
-        val ziplineToken = sharedPreferences?.getString("ziplineToken", null)
-        Log.d("Short[onViewCreated]", "ziplineUrl: $ziplineUrl")
-        Log.d("Short[onViewCreated]", "ziplineToken: $ziplineToken")
-
-        if (ziplineUrl == null || ziplineToken == null) {
-            Log.e("Short[onViewCreated]", "ziplineUrl || ziplineToken is null")
-            Toast.makeText(requireContext(), "Missing Zipline Authentication!", Toast.LENGTH_LONG)
-                .show()
-            navController.navigate(
-                R.id.nav_item_setup, null, NavOptions.Builder()
-                    .setPopUpTo(R.id.nav_item_home, true)
-                    .build()
-            )
-            return
-        }
-
-        binding.urlText.setText(url)
-
-        binding.shareButton.setOnClickListener {
-            Log.d("shareButton", "setOnClickListener")
-            val shareIntent = Intent(Intent.ACTION_SEND).apply {
-                type = "text/plain"
-                putExtra(Intent.EXTRA_TEXT, url)
-            }
-            startActivity(Intent.createChooser(shareIntent, null))
-        }
-
-        binding.optionsButton.setOnClickListener {
-            Log.d("optionsButton", "setOnClickListener")
-            navController.navigate(R.id.nav_item_settings)
-        }
-
-        binding.openButton.setOnClickListener {
-            Log.d("openButton", "setOnClickListener")
-            val openIntent = Intent(Intent.ACTION_VIEW).apply {
-                data = url.toUri()
-            }
-            startActivity(Intent.createChooser(openIntent, null))
-        }
-
-        binding.shortButton.setOnClickListener {
-            val longUrl = binding.urlText.text.toString().trim()
-            Log.d("uploadButton", "longUrl: $longUrl")
-            val vanityName = binding.vanityName.text.toString().trim()
-            Log.d("uploadButton", "vanityName: $vanityName")
-            processShort(longUrl, vanityName)
-        }
-    }
-
-    private fun processShort(longUrl: String, vanityName: String?) {
-        Log.d("processShort", "URL: $longUrl")
-        Log.d("processShort", "Vanity: $vanityName")
-
-        val sharedPreferences = context?.getSharedPreferences("default_preferences", MODE_PRIVATE)
-        val ziplineUrl = sharedPreferences?.getString("ziplineUrl", null)
-        val ziplineToken = sharedPreferences?.getString("ziplineToken", null)
-        Log.d("processShort", "ziplineUrl: $ziplineUrl")
-        Log.d("processShort", "ziplineToken: $ziplineToken")
-
-        if (ziplineUrl == null || ziplineToken == null) {
-            Log.e("processShort", "ziplineUrl || ziplineToken is null")
-            Toast.makeText(requireContext(), "Missing Zipline Authentication!", Toast.LENGTH_LONG)
-                .show()
-            navController.navigate(
-                R.id.nav_item_setup, null, NavOptions.Builder()
-                    .setPopUpTo(R.id.nav_item_home, true)
-                    .build()
-            )
-            return
-        }
-
-        val api = ZiplineApi(requireContext())
-        lifecycleScope.launch {
-            val response = api.shorten(longUrl, vanityName, ziplineUrl)
-            Log.d("processShort", "response: $response")
-            if (response != null) {
-                Log.d("processShort", "response.url: ${response.url}")
-                copyToClipboard(response.url)
-                val shareUrl = sharedPreferences.getBoolean("share_after_short", true)
-                Log.d("processShort", "shareUrl: $shareUrl")
-                if (shareUrl) {
-                    val shareIntent = Intent(Intent.ACTION_SEND).apply {
-                        type = "text/plain"
-                        putExtra(Intent.EXTRA_TEXT, response.url)
-                    }
-                    startActivity(Intent.createChooser(shareIntent, null))
-                }
-                navController.navigate(
-                    R.id.nav_item_home,
-                    bundleOf("url" to "${ziplineUrl}/dashboard/urls"),
-                    NavOptions.Builder()
-                        .setPopUpTo(R.id.nav_graph, inclusive = true)
-                        .build()
-                )
-                Log.d("processShort", "DONE")
-            } else {
-                Log.e("processShort", "uploadedFile is null")
-                withContext(Dispatchers.Main) {
-                    Toast.makeText(requireContext(), "File Upload Failed!", Toast.LENGTH_LONG)
-                        .show()
-                }
-            }
-        }
-    }
-
-    private fun copyToClipboard(url: String) {
-        Log.d("copyToClipboard", "url: $url")
-        val clipboard = requireContext().getSystemService(CLIPBOARD_SERVICE) as ClipboardManager
-        val clip = ClipData.newPlainText("URL", url)
-        clipboard.setPrimaryClip(clip)
-        Toast.makeText(requireContext(), "Copied URL to Clipboard.", Toast.LENGTH_SHORT).show()
-    }
-}
diff --git a/app/src/main/java/org/cssnr/zipline/ZiplineApi.kt b/app/src/main/java/org/cssnr/zipline/ZiplineApi.kt
index e5d5ae3..9dd54b0 100644
--- a/app/src/main/java/org/cssnr/zipline/ZiplineApi.kt
+++ b/app/src/main/java/org/cssnr/zipline/ZiplineApi.kt
@@ -19,7 +19,6 @@ import okhttp3.MediaType.Companion.toMediaTypeOrNull
 import okhttp3.MultipartBody
 import okhttp3.OkHttpClient
 import okhttp3.RequestBody.Companion.toRequestBody
-import retrofit2.HttpException
 import retrofit2.Response
 import retrofit2.Retrofit
 import retrofit2.converter.gson.GsonConverterFactory
@@ -69,77 +68,45 @@ class ZiplineApi(private val context: Context) {
         }
     }
 
-    suspend fun shorten(url: String, vanity: String?, ziplineUrl: String): ShortResponse? {
+    suspend fun shorten(url: String, vanity: String?, ziplineUrl: String): Response {
         Log.d("upload", "url: $url")
         Log.d("upload", "vanity: $vanity")
         Log.d("upload", "ziplineUrl: $ziplineUrl")
 
-        val ziplineToken = preferences.getString("ziplineToken", null)
-        Log.d("upload", "ziplineToken: $ziplineToken")
-        if (ziplineToken == null) {
-            Log.e("upload", "inputStream/ziplineToken is null")
-            return null
-        }
-
+        val ziplineToken = preferences.getString("ziplineToken", null) ?: ""
         val api = createRetrofit(ziplineUrl).create(ApiService::class.java)
-        return try {
-            api.postShort(ziplineToken, ShortRequest(url, vanity, true))
-        } catch (e: HttpException) {
-            Log.e("upload", "HttpException: ${e.message}")
-            val response = e.response()?.errorBody()?.string()
-            Log.d("upload", "response: $response")
-            if (e.code() == 401) {
-                try {
-                    val token = reAuthenticate(api, ziplineUrl)
-                    if (!token.isNullOrEmpty()) {
-                        api.postShort(ziplineToken, ShortRequest(url, vanity, true))
-                    }
-                } catch (e: Exception) {
-                    Log.w("upload", "Exception: ${e.message}")
-                }
+        val response = api.postShort(ziplineToken, ShortRequest(url, vanity, true))
+        if (response.code() == 401) {
+            val token = reAuthenticate(api, ziplineUrl)
+            Log.d("Api[upload]", "token: $token")
+            if (token != null) {
+                return api.postShort(token, ShortRequest(url, vanity, true))
             }
-            null
-        } catch (e: Exception) {
-            Log.e("upload", "Exception: ${e.message}")
-            null
         }
+        return response
     }
 
-    suspend fun upload(uri: Uri, fileName: String, ziplineUrl: String): FileResponse? {
-        Log.d("upload", "uri: $uri")
+    suspend fun upload(
+        fileName: String,
+        inputStream: InputStream,
+        ziplineUrl: String
+    ): Response {
         Log.d("upload", "fileName: $fileName")
-        val ziplineToken = preferences.getString("ziplineToken", null)
+        val ziplineToken = preferences.getString("ziplineToken", null) ?: ""
         Log.d("upload", "ziplineToken: $ziplineToken")
-        val fileNameFormat = preferences.getString("file_name_format", "random")
+        val fileNameFormat = preferences.getString("file_name_format", null) ?: "random"
         Log.d("upload", "fileNameFormat: $fileNameFormat")
-        val inputStream = context.contentResolver.openInputStream(uri)
-        if (ziplineToken == null || inputStream == null) {
-            Log.e("upload", "inputStream/ziplineToken is null")
-            return null
-        }
         val api = createRetrofit(ziplineUrl).create(ApiService::class.java)
         val multiPart: MultipartBody.Part = inputStreamToMultipart(inputStream, fileName)
-        return try {
-            api.postUpload(ziplineToken, fileNameFormat.toString(), multiPart)
-        } catch (e: HttpException) {
-            Log.e("upload", "HttpException: ${e.message}")
-            val response = e.response()?.errorBody()?.string()
-            Log.d("upload", "response: $response")
-            if (e.code() == 401) {
-                try {
-                    val token = reAuthenticate(api, ziplineUrl)
-                    if (!token.isNullOrEmpty()) {
-                        return api.postUpload(token, fileNameFormat.toString(), multiPart)
-                    }
-                } catch (e: Exception) {
-                    Log.w("upload", "Exception: ${e.message}")
-                }
+        val response = api.postUpload(ziplineToken, fileNameFormat.toString(), multiPart)
+        if (response.code() == 401) {
+            val token = reAuthenticate(api, ziplineUrl)
+            Log.d("Api[upload]", "token: $token")
+            if (token != null) {
+                return api.postUpload(token, fileNameFormat, multiPart)
             }
-            null
-        } catch (e: Exception) {
-            Log.e("upload", "Exception: ${e.message}")
-            null
         }
+        return response
     }
 
     private suspend fun reAuthenticate(api: ApiService, ziplineUrl: String): String? {
@@ -209,13 +176,13 @@ class ZiplineApi(private val context: Context) {
             @Header("authorization") token: String,
             @Header("x-zipline-format") format: String,
             @Part file: MultipartBody.Part,
-        ): FileResponse
+        ): Response
 
         @POST("user/urls")
         suspend fun postShort(
             @Header("authorization") token: String,
             @Body request: ShortRequest,
-        ): ShortResponse
+        ): Response
     }
 
     data class LoginRequest(
@@ -301,4 +268,4 @@ fun getFileNameFromUri(context: Context, uri: Uri): String? {
         }
     }
     return fileName
-}
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/cssnr/zipline/upload/ShortFragment.kt b/app/src/main/java/org/cssnr/zipline/upload/ShortFragment.kt
new file mode 100644
index 0000000..1848859
--- /dev/null
+++ b/app/src/main/java/org/cssnr/zipline/upload/ShortFragment.kt
@@ -0,0 +1,253 @@
+package org.cssnr.zipline.upload
+
+import android.content.Context.MODE_PRIVATE
+import android.content.Intent
+import android.os.Bundle
+import android.util.Log
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import android.widget.Toast
+import androidx.core.net.toUri
+import androidx.core.os.bundleOf
+import androidx.fragment.app.Fragment
+import androidx.lifecycle.lifecycleScope
+import androidx.navigation.NavController
+import androidx.navigation.NavOptions
+import androidx.navigation.fragment.findNavController
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import org.cssnr.zipline.R
+import org.cssnr.zipline.ZiplineApi
+import org.cssnr.zipline.copyToClipboard
+import org.cssnr.zipline.databinding.FragmentShortBinding
+
+class ShortFragment : Fragment() {
+
+    private var _binding: FragmentShortBinding? = null
+    private val binding get() = _binding!!
+
+    private lateinit var navController: NavController
+
+    override fun onCreateView(
+        inflater: LayoutInflater,
+        container: ViewGroup?,
+        savedInstanceState: Bundle?
+    ): View {
+        Log.d("ShortFragment", "onCreateView: $savedInstanceState")
+        _binding = FragmentShortBinding.inflate(inflater, container, false)
+        val root: View = binding.root
+        return root
+    }
+
+    override fun onDestroyView() {
+        Log.d("ShortFragment", "onDestroyView")
+        super.onDestroyView()
+        _binding = null
+    }
+
+    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+        Log.d("Short[onViewCreated]", "savedInstanceState: $savedInstanceState")
+        Log.d("Short[onViewCreated]", "arguments: $arguments")
+
+        navController = findNavController()
+
+        //val callback = object : OnBackPressedCallback(true) {
+        //    override fun handleOnBackPressed() {
+        //        requireActivity().finish()
+        //    }
+        //}
+        //requireActivity().onBackPressedDispatcher.addCallback(viewLifecycleOwner, callback)
+
+        val url = requireArguments().getString("url")
+        Log.d("Short[onViewCreated]", "url: $url")
+
+        if (url == null) {
+            // TODO: Better Handle this Error
+            Log.e("Short[onViewCreated]", "URL is null")
+            Toast.makeText(requireContext(), "No URL to Process!", Toast.LENGTH_LONG).show()
+            return
+        }
+
+        val sharedPreferences = context?.getSharedPreferences("default_preferences", MODE_PRIVATE)
+        val savedUrl = sharedPreferences?.getString("ziplineUrl", null)
+        Log.d("Short[onViewCreated]", "savedUrl: $savedUrl")
+        val authToken = sharedPreferences?.getString("ziplineToken", null)
+        Log.d("Short[onViewCreated]", "authToken: $authToken")
+
+        if (savedUrl == null) {
+            Log.e("Short[onViewCreated]", "savedUrl is null")
+            Toast.makeText(requireContext(), "Missing URL!", Toast.LENGTH_LONG)
+                .show()
+            navController.navigate(
+                R.id.nav_item_setup, null, NavOptions.Builder()
+                    .setPopUpTo(R.id.nav_item_home, true)
+                    .build()
+            )
+            return
+        }
+
+        binding.urlText.setText(url)
+
+        binding.shareButton.setOnClickListener {
+            Log.d("shareButton", "setOnClickListener")
+            val shareIntent = Intent(Intent.ACTION_SEND).apply {
+                type = "text/plain"
+                putExtra(Intent.EXTRA_TEXT, url)
+            }
+            startActivity(Intent.createChooser(shareIntent, null))
+        }
+
+        binding.optionsButton.setOnClickListener {
+            Log.d("optionsButton", "setOnClickListener")
+            navController.navigate(R.id.nav_item_settings)
+        }
+
+        binding.openButton.setOnClickListener {
+            Log.d("openButton", "setOnClickListener")
+            val openIntent = Intent(Intent.ACTION_VIEW).apply {
+                data = url.toUri()
+            }
+            startActivity(Intent.createChooser(openIntent, null))
+        }
+
+        binding.shortButton.setOnClickListener {
+            val longUrl = binding.urlText.text.toString().trim()
+            Log.d("uploadButton", "longUrl: $longUrl")
+            val vanityName = binding.vanityName.text.toString().trim()
+            Log.d("uploadButton", "vanityName: $vanityName")
+            processShort(longUrl, vanityName)
+        }
+    }
+
+    private fun processShort(longUrl: String, vanityName: String?) {
+        Log.d("processShort", "URL: $longUrl")
+        Log.d("processShort", "Vanity: $vanityName")
+
+        val sharedPreferences = context?.getSharedPreferences("default_preferences", MODE_PRIVATE)
+        val ziplineUrl = sharedPreferences?.getString("ziplineUrl", null)
+        val ziplineToken = sharedPreferences?.getString("ziplineToken", null)
+        Log.d("processShort", "ziplineUrl: $ziplineUrl")
+        Log.d("processShort", "ziplineToken: $ziplineToken")
+
+        if (ziplineUrl == null || ziplineToken == null) {
+            Log.e("processShort", "ziplineUrl || ziplineToken is null")
+            Toast.makeText(requireContext(), "Missing Zipline Authentication!", Toast.LENGTH_LONG)
+                .show()
+            navController.navigate(
+                R.id.nav_item_setup, null, NavOptions.Builder()
+                    .setPopUpTo(R.id.nav_item_home, true)
+                    .build()
+            )
+            return
+        }
+
+        val api = ZiplineApi(requireContext())
+        lifecycleScope.launch {
+            val response = api.shorten(longUrl, vanityName, ziplineUrl)
+            Log.d("processShort", "response: $response")
+            if (response.isSuccessful) {
+                val shortResponse = response.body()
+                if (shortResponse != null) {
+                    Log.d("processShort", "shortResponse.url: ${shortResponse.url}")
+                    copyToClipboard(requireContext(), shortResponse.url)
+                    val shareUrl = sharedPreferences.getBoolean("share_after_short", true)
+                    Log.d("processShort", "shareUrl: $shareUrl")
+                    if (shareUrl) {
+                        val shareIntent = Intent(Intent.ACTION_SEND).apply {
+                            type = "text/plain"
+                            putExtra(Intent.EXTRA_TEXT, shortResponse.url)
+                        }
+                        startActivity(Intent.createChooser(shareIntent, null))
+                    }
+                    navController.navigate(
+                        R.id.nav_item_home,
+                        bundleOf("url" to "${ziplineUrl}/dashboard/urls"),
+                        NavOptions.Builder()
+                            .setPopUpTo(R.id.nav_graph, inclusive = true)
+                            .build()
+                    )
+                    Log.d("processShort", "DONE")
+                    return@launch
+                }
+            }
+            Log.e("processShort", "response/shortResponse is null")
+            withContext(Dispatchers.Main) {
+                Toast.makeText(requireContext(), "File Upload Failed!", Toast.LENGTH_LONG)
+                    .show()
+            }
+        }
+    }
+
+//    // TODO: This is the DjangoFiles processShort, Old Zipline processShort was used above...
+//    private fun processShort(url: String, vanity: String?) {
+//        Log.d("processShort", "url: $url")
+//        Log.d("processShort", "vanity: $vanity")
+//        //val preferences = getSharedPreferences(PREFS_NAME, MODE_PRIVATE)
+//        val sharedPreferences =
+//            requireContext().getSharedPreferences("default_preferences", MODE_PRIVATE)
+//        val savedUrl = sharedPreferences.getString("ziplineUrl", null)
+//        Log.d("processShort", "savedUrl: $savedUrl")
+//        val authToken = sharedPreferences.getString("ziplineToken", null)
+//        Log.d("processShort", "authToken: $authToken")
+//        if (savedUrl == null || authToken == null) {
+//            // TODO: Show settings dialog here...
+//            Log.w("processShort", "Missing OR savedUrl/authToken")
+//            Toast.makeText(requireContext(), getString(R.string.tst_no_url), Toast.LENGTH_SHORT)
+//                .show()
+//            return
+//        }
+//        val api = ZiplineApi(requireContext())
+//        Log.d("processShort", "api: $api")
+//        Toast.makeText(requireContext(), "Creating Short URL...", Toast.LENGTH_SHORT).show()
+//        lifecycleScope.launch {
+//            try {
+//                val response = api.shorten(url, vanity, savedUrl)
+//                Log.d("processShort", "response: $response")
+//                if (response.isSuccessful) {
+//                    val shortResponse = response.body()
+//                    Log.d("processShort", "shortResponse: $shortResponse")
+//                    withContext(Dispatchers.Main) {
+//                        if (shortResponse != null) {
+//                            copyToClipboard(requireContext(), shortResponse.url)
+//                            val shareUrl = sharedPreferences.getBoolean("share_after_short", true)
+//                            Log.d("processShort", "shareUrl: $shareUrl")
+//                            if (shareUrl) {
+//                                val shareIntent = Intent(Intent.ACTION_SEND).apply {
+//                                    type = "text/plain"
+//                                    putExtra(Intent.EXTRA_TEXT, shortResponse.url)
+//                                }
+//                                startActivity(Intent.createChooser(shareIntent, null))
+//                            }
+//                            navController.navigate(
+//                                R.id.nav_item_home,
+//                                bundleOf("url" to "${savedUrl}/shorts/#shorts-table_wrapper"),
+//                                NavOptions.Builder()
+//                                    .setPopUpTo(R.id.nav_graph, inclusive = true)
+//                                    .build()
+//                            )
+//                        } else {
+//                            Log.w("processShort", "shortResponse is null")
+//                            val msg = "Unknown Response!"
+//                            Toast.makeText(requireContext(), msg, Toast.LENGTH_LONG).show()
+//                        }
+//                    }
+//                } else {
+//                    val msg = "Error: ${response.code()}: ${response.message()}"
+//                    Log.w("processShort", "Error: $msg")
+//                    withContext(Dispatchers.Main) {
+//                        Toast.makeText(requireContext(), msg, Toast.LENGTH_LONG).show()
+//                    }
+//                }
+//            } catch (e: Exception) {
+//                e.printStackTrace()
+//                val msg = e.message ?: "Unknown Error!"
+//                Log.i("processShort", "msg: $msg")
+//                withContext(Dispatchers.Main) {
+//                    Toast.makeText(requireContext(), msg, Toast.LENGTH_LONG).show()
+//                }
+//            }
+//        }
+//    }
+}
diff --git a/app/src/main/java/org/cssnr/zipline/upload/UploadFragment.kt b/app/src/main/java/org/cssnr/zipline/upload/UploadFragment.kt
new file mode 100644
index 0000000..98a42f4
--- /dev/null
+++ b/app/src/main/java/org/cssnr/zipline/upload/UploadFragment.kt
@@ -0,0 +1,392 @@
+package org.cssnr.zipline.upload
+
+import android.annotation.SuppressLint
+import android.content.Context
+import android.content.Context.MODE_PRIVATE
+import android.content.Intent
+import android.graphics.PorterDuff
+import android.net.Uri
+import android.os.Bundle
+import android.provider.OpenableColumns
+import android.util.Log
+import android.util.TypedValue
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import android.webkit.JavascriptInterface
+import android.webkit.WebView
+import android.widget.Toast
+import androidx.annotation.OptIn
+import androidx.core.content.ContextCompat
+import androidx.core.graphics.ColorUtils
+import androidx.core.net.toUri
+import androidx.core.os.bundleOf
+import androidx.fragment.app.Fragment
+import androidx.lifecycle.lifecycleScope
+import androidx.media3.common.MediaItem
+import androidx.media3.common.util.UnstableApi
+import androidx.media3.datasource.DefaultDataSource
+import androidx.media3.exoplayer.ExoPlayer
+import androidx.media3.exoplayer.source.ProgressiveMediaSource
+import androidx.navigation.NavController
+import androidx.navigation.NavOptions
+import androidx.navigation.fragment.findNavController
+import com.bumptech.glide.Glide
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import org.cssnr.zipline.R
+import org.cssnr.zipline.ZiplineApi
+import org.cssnr.zipline.copyToClipboard
+import org.cssnr.zipline.databinding.FragmentUploadBinding
+import org.json.JSONObject
+
+class UploadFragment : Fragment() {
+
+    private var _binding: FragmentUploadBinding? = null
+    private val binding get() = _binding!!
+
+    private lateinit var navController: NavController
+
+    private lateinit var player: ExoPlayer
+    private lateinit var webView: WebView
+
+    override fun onCreateView(
+        inflater: LayoutInflater,
+        container: ViewGroup?,
+        savedInstanceState: Bundle?
+    ): View {
+        Log.d("UploadFragment", "onCreateView: $savedInstanceState")
+        _binding = FragmentUploadBinding.inflate(inflater, container, false)
+        val root: View = binding.root
+        return root
+    }
+
+    override fun onDestroyView() {
+        Log.d("UploadFragment", "onDestroyView")
+        super.onDestroyView()
+        if (::player.isInitialized) {
+            Log.d("UploadFragment", "player.release")
+            player.release()
+        }
+        if (::webView.isInitialized) {
+            Log.d("UploadFragment", "webView.destroy")
+            webView.destroy()
+        }
+        _binding = null
+    }
+
+    @SuppressLint("SetJavaScriptEnabled")
+    @OptIn(UnstableApi::class)
+    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+        Log.d("Upload[onViewCreated]", "savedInstanceState: $savedInstanceState")
+        Log.d("Upload[onViewCreated]", "arguments: $arguments")
+
+        navController = findNavController()
+
+        //val callback = object : OnBackPressedCallback(true) {
+        //    override fun handleOnBackPressed() {
+        //        requireActivity().finish()
+        //    }
+        //}
+        //requireActivity().onBackPressedDispatcher.addCallback(viewLifecycleOwner, callback)
+
+        //val intent = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+        //    requireArguments().getParcelable("EXTRA_INTENT", Intent::class.java)
+        //} else {
+        //    @Suppress("DEPRECATION")
+        //    requireArguments().getParcelable("EXTRA_INTENT") as? Intent
+        //}
+        //Log.d("Upload[onViewCreated]", "intent: $intent")
+
+        val uri = requireArguments().getString("uri")?.toUri()
+        Log.d("Upload[onViewCreated]", "uri: $uri")
+
+        if (uri == null) {
+            // TODO: Better Handle this Error
+            Log.e("Upload[onViewCreated]", "URI is null")
+            Toast.makeText(requireContext(), "No URI to Process!", Toast.LENGTH_LONG).show()
+            return
+        }
+
+        val mimeType = requireContext().contentResolver.getType(uri)
+        Log.d("Upload[onViewCreated]", "mimeType: $mimeType")
+
+        val fileName = getFileNameFromUri(requireContext(), uri)
+        Log.d("Upload[onViewCreated]", "fileName: $fileName")
+        binding.fileName.setText(fileName)
+
+        // TODO: Overhaul with Glide and ExoPlayer...
+        if (mimeType?.startsWith("video/") == true || mimeType?.startsWith("audio/") == true) {
+            Log.d("Upload[onViewCreated]", "EXOPLAYER")
+            binding.playerView.visibility = View.VISIBLE
+
+            player = ExoPlayer.Builder(requireContext()).build()
+            binding.playerView.player = player
+            binding.playerView.controllerShowTimeoutMs = 1000
+            binding.playerView.setShowNextButton(false)
+            binding.playerView.setShowPreviousButton(false)
+            val dataSourceFactory = DefaultDataSource.Factory(requireContext())
+            val mediaSource = ProgressiveMediaSource.Factory(dataSourceFactory)
+                .createMediaSource(MediaItem.fromUri(uri))
+            player.setMediaSource(mediaSource)
+            player.prepare()
+
+        } else if (isGlideMime(mimeType.toString())) {
+            Log.d("Upload[onViewCreated]", "GLIDE")
+            binding.imageHolder.visibility = View.VISIBLE
+
+            Glide.with(binding.imagePreview).load(uri).into(binding.imagePreview)
+
+        } else if (mimeType?.startsWith("text/") == true || isCodeMime(mimeType!!)) {
+            Log.d("Upload[onViewCreated]", "WEBVIEW")
+            webView = WebView(requireContext())
+            binding.frameLayout.addView(webView)
+
+            val url = "file:///android_asset/preview/preview.html"
+            Log.d("Upload[onViewCreated]", "url: $url")
+
+            val content = requireContext().contentResolver.openInputStream(uri)?.bufferedReader()
+                ?.use { it.readText() }
+            if (content == null) {
+                // TODO: Handle null content error...
+                Log.w("Upload[onViewCreated]", "content is null")
+                return
+            }
+            //Log.d("Upload[onViewCreated]", "content: $content")
+            val escapedContent = JSONObject.quote(content)
+            //Log.d("Upload[onViewCreated]", "escapedContent: $escapedContent")
+            val jsString = "addContent(${escapedContent});"
+            //Log.d("Upload[onViewCreated]", "jsString: $jsString")
+            webView.apply {
+                settings.javaScriptEnabled = true
+                addJavascriptInterface(object {
+                    @JavascriptInterface
+                    fun notifyReady() {
+                        webView.post {
+                            Log.i("Upload[onViewCreated]", "evaluateJavascript")
+                            webView.evaluateJavascript(jsString, null)
+                        }
+                    }
+                }, "Android")
+                Log.d("Upload[onViewCreated]", "loadUrl: $url")
+                loadUrl(url)
+            }
+
+        } else {
+            Log.d("Upload[onViewCreated]", "OTHER")
+            binding.imageHolder.visibility = View.VISIBLE
+
+            // Set Tint of Icon
+            val typedValue = TypedValue()
+            val theme = binding.imagePreview.context.theme
+            theme.resolveAttribute(android.R.attr.textColorPrimary, typedValue, true)
+            val tint = ContextCompat.getColor(binding.imagePreview.context, typedValue.resourceId)
+            val dimmedTint = ColorUtils.setAlphaComponent(tint, (0.5f * 255).toInt())
+            binding.imagePreview.setColorFilter(dimmedTint, PorterDuff.Mode.SRC_IN)
+            // Set Mime Type Text
+            binding.imageOverlayText.text = mimeType
+            binding.imageOverlayText.visibility = View.VISIBLE
+            // Set Icon Based on Type
+            binding.imagePreview.setImageResource(getGenericIcon(mimeType.toString()))
+        }
+
+        binding.shareButton.setOnClickListener {
+            Log.d("shareButton", "setOnClickListener")
+            val shareIntent = Intent(Intent.ACTION_SEND).apply {
+                this.type = mimeType
+                putExtra(Intent.EXTRA_STREAM, uri)
+                addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
+            }
+            startActivity(Intent.createChooser(shareIntent, null))
+        }
+
+        binding.optionsButton.setOnClickListener {
+            Log.d("optionsButton", "setOnClickListener")
+            navController.navigate(R.id.nav_item_settings)
+        }
+
+        binding.openButton.setOnClickListener {
+            Log.d("openButton", "setOnClickListener")
+            val openIntent = Intent(Intent.ACTION_VIEW).apply {
+                setDataAndType(uri, mimeType)
+                addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
+            }
+            startActivity(Intent.createChooser(openIntent, null))
+        }
+
+        binding.uploadButton.setOnClickListener {
+            val fileName = binding.fileName.text.toString().trim()
+            Log.d("uploadButton", "fileName: $fileName")
+            processUpload(uri, fileName)
+        }
+    }
+
+    // TODO: DUPLICATION: ShortFragment.processShort
+    private fun processUpload(fileUri: Uri, fileName: String?) {
+        Log.d("processUpload", "fileUri: $fileUri")
+        //val preferences = getSharedPreferences(PREFS_NAME, MODE_PRIVATE)
+        val sharedPreferences =
+            requireContext().getSharedPreferences("default_preferences", MODE_PRIVATE)
+        val savedUrl = sharedPreferences.getString("ziplineUrl", null)
+        Log.d("processUpload", "savedUrl: $savedUrl")
+        val authToken = sharedPreferences.getString("ziplineToken", null)
+        Log.d("processUpload", "authToken: $authToken")
+        val fileName = fileName ?: getFileNameFromUri(requireContext(), fileUri)
+        Log.d("processUpload", "fileName: $fileName")
+        if (savedUrl == null || authToken == null || fileName == null) {
+            // TODO: Show settings dialog here...
+            Log.w("processUpload", "Missing OR savedUrl/authToken/fileName")
+            Toast.makeText(requireContext(), getString(R.string.tst_no_url), Toast.LENGTH_SHORT)
+                .show()
+            return
+        }
+        //val contentType = URLConnection.guessContentTypeFromName(fileName)
+        //Log.d("processUpload", "contentType: $contentType")
+        val inputStream = requireContext().contentResolver.openInputStream(fileUri)
+        if (inputStream == null) {
+            Log.w("processUpload", "inputStream is null")
+            val msg = getString(R.string.tst_upload_error)
+            Toast.makeText(requireContext(), msg, Toast.LENGTH_SHORT).show()
+            return
+        }
+        val api = ZiplineApi(requireContext())
+        Log.d("processUpload", "api: $api")
+        Toast.makeText(requireContext(), getString(R.string.tst_uploading_file), Toast.LENGTH_SHORT)
+            .show()
+        lifecycleScope.launch {
+            try {
+                val response = api.upload(fileName, inputStream, savedUrl)
+                Log.d("processUpload", "response: $response")
+                if (response.isSuccessful) {
+                    val uploadResponse = response.body()
+                    Log.d("processUpload", "uploadResponse: $uploadResponse")
+                    withContext(Dispatchers.Main) {
+                        if (uploadResponse != null) {
+                            copyToClipboard(requireContext(), uploadResponse.files.first().url)
+                            navController.navigate(
+                                R.id.nav_item_home,
+                                bundleOf("url" to uploadResponse.files.first().url),
+                                NavOptions.Builder()
+                                    .setPopUpTo(R.id.nav_graph, inclusive = true)
+                                    .build()
+                            )
+                        } else {
+                            Log.w("processUpload", "uploadResponse is null")
+                            val msg = "Unknown Response!"
+                            Toast.makeText(requireContext(), msg, Toast.LENGTH_LONG).show()
+                        }
+                    }
+                } else {
+                    val msg = "Error: ${response.code()}: ${response.message()}"
+                    Log.w("processUpload", "Error: $msg")
+                    withContext(Dispatchers.Main) {
+                        Toast.makeText(requireContext(), msg, Toast.LENGTH_LONG).show()
+                    }
+                }
+            } catch (e: Exception) {
+                e.printStackTrace()
+                val msg = e.message ?: "Unknown Error!"
+                Log.i("processUpload", "msg: $msg")
+                withContext(Dispatchers.Main) {
+                    Toast.makeText(requireContext(), msg, Toast.LENGTH_LONG).show()
+                }
+            }
+        }
+    }
+
+    override fun onStop() {
+        Log.d("Upload[onStop]", "1 - ON STOP")
+        super.onStop()
+        if (::player.isInitialized) {
+            Log.d("Upload[onStop]", "player.isPlaying: ${player.isPlaying}")
+            if (player.isPlaying) {
+                Log.d("Upload[onStop]", "player.pause")
+                player.pause()
+            }
+        }
+    }
+}
+
+fun getFileNameFromUri(context: Context, uri: Uri): String? {
+    var fileName: String? = null
+    context.contentResolver.query(uri, null, null, null, null).use { cursor ->
+        if (cursor != null && cursor.moveToFirst()) {
+            val nameIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
+            if (nameIndex != -1) {
+                fileName = cursor.getString(nameIndex)
+            }
+        }
+    }
+    return fileName
+}
+
+
+fun openUrl(context: Context, url: String) {
+    val openIntent = Intent(Intent.ACTION_VIEW).apply {
+        setDataAndType(url.toUri(), "text/plain")
+        addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
+    }
+    context.startActivity(Intent.createChooser(openIntent, null))
+}
+
+fun shareUrl(context: Context, url: String) {
+    val shareIntent = Intent(Intent.ACTION_SEND).apply {
+        type = "text/plain"
+        putExtra(Intent.EXTRA_TEXT, url)
+    }
+    context.startActivity(Intent.createChooser(shareIntent, null))
+}
+
+fun isGlideMime(mimeType: String): Boolean {
+    return when (mimeType.lowercase()) {
+        "image/jpeg",
+        "image/png",
+        "image/gif",
+        "image/webp",
+        "image/heif",
+            -> true
+
+        else -> false
+    }
+}
+
+fun isCodeMime(mimeType: String): Boolean {
+    if (mimeType.startsWith("text/x-script")) return true
+    return when (mimeType.lowercase()) {
+        "application/atom+xml",
+        "application/javascript",
+        "application/json",
+        "application/ld+json",
+        "application/rss+xml",
+        "application/xml",
+        "application/x-httpd-php",
+        "application/x-python",
+        "application/x-www-form-urlencoded",
+        "application/yaml",
+        "text/javascript",
+        "text/python",
+        "text/x-go",
+        "text/x-ruby",
+        "text/x-php",
+        "text/x-python",
+        "text/x-shellscript",
+            -> true
+
+        else -> false
+    }
+}
+
+fun getGenericIcon(mimeType: String): Int = when {
+    isCodeMime(mimeType) -> R.drawable.md_code_blocks_24px
+    mimeType.startsWith("application/json") -> R.drawable.md_file_json_24px
+    mimeType.startsWith("application/pdf") -> R.drawable.md_picture_as_pdf_24px
+    mimeType.startsWith("image/gif") -> R.drawable.md_gif_box_24px
+    mimeType.startsWith("image/png") -> R.drawable.md_file_png_24px
+    mimeType.startsWith("text/csv") -> R.drawable.md_csv_24px
+    mimeType.startsWith("audio/") -> R.drawable.md_music_note_24px
+    mimeType.startsWith("image/") -> R.drawable.md_imagesmode_24px
+    mimeType.startsWith("text/") -> R.drawable.md_docs_24px
+    mimeType.startsWith("video/") -> R.drawable.md_videocam_24px
+    else -> R.drawable.md_unknown_document_24px
+}
diff --git a/app/src/main/java/org/cssnr/zipline/upload/UploadMultiAdapter.kt b/app/src/main/java/org/cssnr/zipline/upload/UploadMultiAdapter.kt
new file mode 100644
index 0000000..918d06b
--- /dev/null
+++ b/app/src/main/java/org/cssnr/zipline/upload/UploadMultiAdapter.kt
@@ -0,0 +1,115 @@
+package org.cssnr.zipline.upload
+
+import android.content.Context
+import android.net.Uri
+import android.util.Log
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import android.widget.FrameLayout
+import android.widget.ImageView
+import android.widget.TextView
+import androidx.recyclerview.widget.RecyclerView
+import com.bumptech.glide.Glide
+import org.cssnr.zipline.R
+
+class UploadMultiAdapter(
+    private val dataSet: List,
+    private val selectedUris: MutableSet,
+    private val onItemClick: (MutableSet) -> Unit,
+) : RecyclerView.Adapter() {
+
+    private lateinit var context: Context
+
+    // TODO: Consider moving this to a ViewModel...
+
+    //// Note: This data type redraws the list every time
+    //val selectedUris = mutableSetOf()
+    //init {
+    //    selectedUris.addAll(dataSet)
+    //}
+
+    init {
+        setHasStableIds(true)
+    }
+
+    override fun getItemId(position: Int): Long {
+        return dataSet[position].hashCode().toLong()
+    }
+
+    class ViewHolder(val view: View) : RecyclerView.ViewHolder(view) {
+        val imageHolder: FrameLayout = view.findViewById(R.id.image_holder)
+        val itemSelect: FrameLayout = view.findViewById(R.id.item_select)
+        val imageView: ImageView = view.findViewById(R.id.image_view)
+        val fileText: TextView = view.findViewById(R.id.file_name)
+        val checKMark: ImageView = view.findViewById(R.id.check_mark)
+    }
+
+    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
+        context = parent.context
+        val view = LayoutInflater.from(parent.context)
+            .inflate(R.layout.file_item_upload, parent, false)
+        return ViewHolder(view)
+    }
+
+    override fun onBindViewHolder(holder: ViewHolder, position: Int) {
+        //Log.d("Multi[onBindViewHolder]", "position: $position")
+        val data = dataSet[position]
+        //Log.d("Multi[onBindViewHolder]", "data: $data")
+
+        val mimeType = context.contentResolver.getType(data)
+        //Log.d("Multi[onBindViewHolder]", "mimeType: $mimeType")
+
+        if (mimeType != null && isGlideMime(mimeType)) {
+            Glide.with(holder.imageView).load(data).into(holder.imageView)
+        } else {
+            holder.imageView.setImageResource(getGenericIcon(mimeType!!))
+        }
+
+        val fileName = getFileNameFromUri(context, data)
+        //Log.d("Multi[onBindViewHolder]", "fileName: $fileName")
+        holder.fileText.text = fileName
+
+        if (selectedUris.contains(data)) {
+            holder.checKMark.visibility = View.VISIBLE
+            holder.itemSelect.setBackgroundResource(R.drawable.image_border_selected)
+        } else {
+            holder.checKMark.visibility = View.GONE
+            holder.itemSelect.setBackgroundResource(R.drawable.image_border)
+        }
+
+        holder.itemView.setOnClickListener {
+            Log.d("Adapter[onClick]", "itemView: $position")
+
+            if (selectedUris.contains(data)) {
+                Log.d("Adapter[onClick]", "REMOVE - $data")
+                selectedUris.remove(data)
+                holder.checKMark.visibility = View.GONE
+                holder.itemSelect.setBackgroundResource(R.drawable.image_border)
+            } else {
+                Log.d("Adapter[onClick]", "ADD - $data")
+                selectedUris.add(data)
+                holder.checKMark.visibility = View.VISIBLE
+                holder.itemSelect.setBackgroundResource(R.drawable.image_border_selected)
+            }
+            notifyItemChanged(holder.bindingAdapterPosition)
+
+            onItemClick(selectedUris)
+        }
+
+        //val screenWidth = holder.itemView.resources.displayMetrics.widthPixels
+        ////val itemSpacing = 12 * 2 + 4 * 2 // parent padding + item padding (in pixels)
+        //val itemSpacing = 64
+        //val spanCount = 2
+        //val size = (screenWidth - itemSpacing) / spanCount
+        //Log.d("UploadMultiAdapter", "size: $size")
+        //
+        //holder.itemView.layoutParams.width = size
+        //holder.itemView.layoutParams.height = size
+        //Log.d("UploadMultiAdapter", "width: ${holder.itemView.layoutParams.width}")
+        //Log.d("UploadMultiAdapter", "height: ${holder.itemView.layoutParams.height}")
+
+    }
+
+    override fun getItemCount(): Int = dataSet.size
+}
diff --git a/app/src/main/java/org/cssnr/zipline/upload/UploadMultiFragment.kt b/app/src/main/java/org/cssnr/zipline/upload/UploadMultiFragment.kt
new file mode 100644
index 0000000..1eed92c
--- /dev/null
+++ b/app/src/main/java/org/cssnr/zipline/upload/UploadMultiFragment.kt
@@ -0,0 +1,208 @@
+package org.cssnr.zipline.upload
+
+import android.content.Context.MODE_PRIVATE
+import android.net.Uri
+import android.os.Build
+import android.os.Bundle
+import android.util.Log
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import android.widget.Toast
+import androidx.core.os.bundleOf
+import androidx.fragment.app.Fragment
+import androidx.fragment.app.activityViewModels
+import androidx.lifecycle.lifecycleScope
+import androidx.navigation.NavController
+import androidx.navigation.NavOptions
+import androidx.navigation.fragment.findNavController
+import androidx.recyclerview.widget.GridLayoutManager
+import kotlinx.coroutines.launch
+import org.cssnr.zipline.R
+import org.cssnr.zipline.ZiplineApi
+import org.cssnr.zipline.ZiplineApi.FileResponse
+import org.cssnr.zipline.databinding.FragmentUploadMultiBinding
+
+class UploadMultiFragment : Fragment() {
+
+    private var _binding: FragmentUploadMultiBinding? = null
+    private val binding get() = _binding!!
+
+    private val viewModel: UploadViewModel by activityViewModels()
+
+    private lateinit var navController: NavController
+    private lateinit var adapter: UploadMultiAdapter
+
+    override fun onCreate(savedInstanceState: Bundle?) {
+        super.onCreate(savedInstanceState)
+    }
+
+    override fun onCreateView(
+        inflater: LayoutInflater,
+        container: ViewGroup?,
+        savedInstanceState: Bundle?
+    ): View {
+        Log.d("Multi[onCreateView]", "savedInstanceState: ${savedInstanceState?.size()}")
+        _binding = FragmentUploadMultiBinding.inflate(inflater, container, false)
+        val root: View = binding.root
+        return root
+    }
+
+    override fun onDestroyView() {
+        Log.d("UploadMultiFragment", "onDestroyView")
+        super.onDestroyView()
+        _binding = null
+    }
+
+    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+        Log.d("Multi[onViewCreated]", "savedInstanceState: ${savedInstanceState?.size()}")
+        Log.d("Multi[onViewCreated]", "arguments: $arguments")
+
+        navController = findNavController()
+
+        val sharedPreferences = context?.getSharedPreferences("default_preferences", MODE_PRIVATE)
+        val savedUrl = sharedPreferences?.getString("ziplineUrl", null)
+        Log.d("Multi[onViewCreated]", "savedUrl: $savedUrl")
+        val authToken = sharedPreferences?.getString("ziplineToken", null)
+        Log.d("Multi[onViewCreated]", "authToken: $authToken")
+
+        if (savedUrl == null) {
+            Log.w("Multi[onViewCreated]", "savedUrl is null")
+            Toast.makeText(requireContext(), "Missing URL!", Toast.LENGTH_LONG)
+                .show()
+            navController.navigate(
+                R.id.nav_item_setup, null, NavOptions.Builder()
+                    .setPopUpTo(R.id.nav_item_home, true)
+                    .build()
+            )
+            return
+        }
+
+        val fileUris = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+            requireArguments().getParcelableArrayList("fileUris", Uri::class.java)
+        } else {
+            @Suppress("DEPRECATION")
+            requireArguments().getParcelableArrayList("fileUris")
+        }
+        if (fileUris == null) {
+            Log.w("Multi[onCreate]", "fileUris is null")
+            return
+        }
+
+        if (viewModel.selectedUris.value == null) {
+            Log.i("Multi[onCreate]", "RESET SELECTED URIS TO ALL")
+            viewModel.selectedUris.value = fileUris.toSet()
+        } else {
+            Log.i("Multi[onCreate]", "USE VIEW MODEL SELECTED URIS")
+        }
+
+        Log.d("Multi[onViewCreated]", "fileUris.size: ${fileUris.size}")
+        val selectedUris = viewModel.selectedUris.value!!.toMutableSet()
+        Log.d("Multi[onViewCreated]", "selectedUris.size: ${selectedUris.size}")
+
+        if (!::adapter.isInitialized) {
+            Log.i("Multi[onViewCreated]", "INITIALIZE NEW ADAPTER")
+            adapter = UploadMultiAdapter(fileUris, selectedUris) { updated ->
+                viewModel.selectedUris.value = updated
+                binding.uploadButton.text = getString(R.string.upload_multi, updated.size)
+            }
+        }
+
+        binding.previewRecycler.layoutManager = GridLayoutManager(requireContext(), 2)
+        if (binding.previewRecycler.adapter == null) {
+            binding.previewRecycler.adapter = adapter
+        }
+
+        // Upload Button
+        binding.uploadButton.text = getString(R.string.upload_multi, selectedUris.size)
+        binding.uploadButton.setOnClickListener {
+            val selectedUris = viewModel.selectedUris.value
+            //Log.d("uploadButton", "selectedUris: $selectedUris")
+            Log.d("uploadButton", "selectedUris.size: ${selectedUris?.size}")
+            if (selectedUris.isNullOrEmpty()) {
+                Toast.makeText(requireContext(), "No Files Selected!", Toast.LENGTH_SHORT).show()
+                return@setOnClickListener
+            }
+            processMultiUpload(selectedUris)
+        }
+
+        // Options Button
+        binding.optionsButton.setOnClickListener {
+            Log.d("optionsButton", "setOnClickListener: navigate: nav_item_settings")
+            navController.navigate(R.id.nav_item_settings)
+        }
+    }
+
+    private fun processMultiUpload(fileUris: Set) {
+        Log.d("processMultiUpload", "fileUris: $fileUris")
+        Log.d("processMultiUpload", "fileUris.size: ${fileUris.size}")
+        val sharedPreferences =
+            requireContext().getSharedPreferences("default_preferences", MODE_PRIVATE)
+        val savedUrl = sharedPreferences.getString("ziplineUrl", null)
+        Log.d("processMultiUpload", "savedUrl: $savedUrl")
+        val authToken = sharedPreferences.getString("ziplineToken", null)
+        Log.d("processMultiUpload", "authToken: $authToken")
+
+        if (savedUrl == null || authToken == null) {
+            // TODO: Show settings dialog here...
+            Log.w("processMultiUpload", "Missing OR savedUrl/authToken/fileName")
+            Toast.makeText(requireContext(), getString(R.string.tst_no_url), Toast.LENGTH_SHORT)
+                .show()
+            return
+        }
+        val msg = "Uploading ${fileUris.size} Files..."
+        Toast.makeText(requireContext(), msg, Toast.LENGTH_SHORT).show()
+
+        val api = ZiplineApi(requireContext())
+        Log.d("processMultiUpload", "api: $api")
+        val results: MutableList = mutableListOf()
+        val currentContext = requireContext()
+        lifecycleScope.launch {
+            for (fileUri in fileUris) {
+                Log.d("processMultiUpload", "fileUri: $fileUri")
+                val fileName = getFileNameFromUri(currentContext, fileUri)
+                Log.d("processMultiUpload", "fileName: $fileName")
+                try {
+                    val inputStream = currentContext.contentResolver.openInputStream(fileUri)
+                    if (inputStream == null) {
+                        Log.w("processMultiUpload", "inputStream is null")
+                        continue
+                    }
+                    val response = api.upload(fileName!!, inputStream, savedUrl)
+                    Log.d("processMultiUpload", "response: $response")
+                    if (response.isSuccessful) {
+                        val fileResponse = response.body()
+                        Log.d("processMultiUpload", "fileResponse: $fileResponse")
+                        if (fileResponse != null) {
+                            results.add(fileResponse)
+                        }
+                    } else {
+                        val msg = "Error: ${response.code()}: ${response.message()}"
+                        Log.w("processMultiUpload", "UPLOAD ERROR: $msg")
+                    }
+                } catch (e: Exception) {
+                    e.printStackTrace()
+                }
+            }
+            Log.d("processMultiUpload", "results: $results")
+            Log.d("processMultiUpload", "results,size: ${results.size}")
+            if (results.isEmpty()) {
+                // TODO: Handle upload failures better...
+                Toast.makeText(requireContext(), "All Uploads Failed!", Toast.LENGTH_SHORT).show()
+                return@launch
+            }
+            val destUrl =
+                if (results.size != 1) "${savedUrl}/dashboard/files/" else results.first().files.first().url
+            Log.d("processMultiUpload", "destUrl: $destUrl")
+            val msg = "Uploaded ${results.size} Files."
+            Toast.makeText(requireContext(), msg, Toast.LENGTH_SHORT).show()
+            navController.navigate(
+                R.id.nav_item_home,
+                bundleOf("url" to destUrl),
+                NavOptions.Builder()
+                    .setPopUpTo(R.id.nav_graph, inclusive = true)
+                    .build()
+            )
+        }
+    }
+}
diff --git a/app/src/main/java/org/cssnr/zipline/upload/UploadViewModel.kt b/app/src/main/java/org/cssnr/zipline/upload/UploadViewModel.kt
new file mode 100644
index 0000000..2a4d713
--- /dev/null
+++ b/app/src/main/java/org/cssnr/zipline/upload/UploadViewModel.kt
@@ -0,0 +1,9 @@
+package org.cssnr.zipline.upload
+
+import android.net.Uri
+import androidx.lifecycle.MutableLiveData
+import androidx.lifecycle.ViewModel
+
+class UploadViewModel : ViewModel() {
+    val selectedUris = MutableLiveData>()
+}
diff --git a/app/src/main/res/drawable/baseline_audio_file_24.xml b/app/src/main/res/drawable/baseline_audio_file_24.xml
deleted file mode 100644
index d1c6566..0000000
--- a/app/src/main/res/drawable/baseline_audio_file_24.xml
+++ /dev/null
@@ -1,5 +0,0 @@
-
-      
-    
-    
-
diff --git a/app/src/main/res/drawable/baseline_file_upload_24.xml b/app/src/main/res/drawable/baseline_file_upload_24.xml
deleted file mode 100644
index 8d84453..0000000
--- a/app/src/main/res/drawable/baseline_file_upload_24.xml
+++ /dev/null
@@ -1,5 +0,0 @@
-
-      
-    
-    
-
diff --git a/app/src/main/res/drawable/baseline_fingerprint_24.xml b/app/src/main/res/drawable/baseline_fingerprint_24.xml
deleted file mode 100644
index 0f7e4f2..0000000
--- a/app/src/main/res/drawable/baseline_fingerprint_24.xml
+++ /dev/null
@@ -1,5 +0,0 @@
-
-      
-    
-    
-
diff --git a/app/src/main/res/drawable/baseline_insert_drive_file_24.xml b/app/src/main/res/drawable/baseline_insert_drive_file_24.xml
deleted file mode 100644
index 99a2078..0000000
--- a/app/src/main/res/drawable/baseline_insert_drive_file_24.xml
+++ /dev/null
@@ -1,5 +0,0 @@
-
-      
-    
-    
-
diff --git a/app/src/main/res/drawable/baseline_key_24.xml b/app/src/main/res/drawable/baseline_key_24.xml
deleted file mode 100644
index 57bcdbc..0000000
--- a/app/src/main/res/drawable/baseline_key_24.xml
+++ /dev/null
@@ -1,5 +0,0 @@
-
-      
-    
-    
-
diff --git a/app/src/main/res/drawable/baseline_link_24.xml b/app/src/main/res/drawable/baseline_link_24.xml
deleted file mode 100644
index 1913f35..0000000
--- a/app/src/main/res/drawable/baseline_link_24.xml
+++ /dev/null
@@ -1,5 +0,0 @@
-
-
-    
-    
-
diff --git a/app/src/main/res/drawable/baseline_login_24.xml b/app/src/main/res/drawable/baseline_login_24.xml
deleted file mode 100644
index 1671190..0000000
--- a/app/src/main/res/drawable/baseline_login_24.xml
+++ /dev/null
@@ -1,5 +0,0 @@
-
-      
-    
-    
-
diff --git a/app/src/main/res/drawable/baseline_menu_24.xml b/app/src/main/res/drawable/baseline_menu_24.xml
deleted file mode 100644
index 436e989..0000000
--- a/app/src/main/res/drawable/baseline_menu_24.xml
+++ /dev/null
@@ -1,5 +0,0 @@
-
-      
-    
-    
-
diff --git a/app/src/main/res/drawable/baseline_open_in_new_24.xml b/app/src/main/res/drawable/baseline_open_in_new_24.xml
deleted file mode 100644
index 213f42b..0000000
--- a/app/src/main/res/drawable/baseline_open_in_new_24.xml
+++ /dev/null
@@ -1,5 +0,0 @@
-
-      
-    
-    
-
diff --git a/app/src/main/res/drawable/baseline_person_24.xml b/app/src/main/res/drawable/baseline_person_24.xml
deleted file mode 100644
index 411ae57..0000000
--- a/app/src/main/res/drawable/baseline_person_24.xml
+++ /dev/null
@@ -1,5 +0,0 @@
-
-      
-    
-    
-
diff --git a/app/src/main/res/drawable/baseline_preview_24.xml b/app/src/main/res/drawable/baseline_preview_24.xml
deleted file mode 100644
index 3e18169..0000000
--- a/app/src/main/res/drawable/baseline_preview_24.xml
+++ /dev/null
@@ -1,5 +0,0 @@
-
-      
-    
-    
-
diff --git a/app/src/main/res/drawable/baseline_settings_24.xml b/app/src/main/res/drawable/baseline_settings_24.xml
deleted file mode 100644
index d8d6e04..0000000
--- a/app/src/main/res/drawable/baseline_settings_24.xml
+++ /dev/null
@@ -1,5 +0,0 @@
-
-      
-    
-    
-
diff --git a/app/src/main/res/drawable/baseline_share_24.xml b/app/src/main/res/drawable/baseline_share_24.xml
deleted file mode 100644
index cf401e3..0000000
--- a/app/src/main/res/drawable/baseline_share_24.xml
+++ /dev/null
@@ -1,5 +0,0 @@
-
-      
-    
-    
-
diff --git a/app/src/main/res/drawable/baseline_text_snippet_24.xml b/app/src/main/res/drawable/baseline_text_snippet_24.xml
deleted file mode 100644
index 7bed223..0000000
--- a/app/src/main/res/drawable/baseline_text_snippet_24.xml
+++ /dev/null
@@ -1,5 +0,0 @@
-
-      
-    
-    
-
diff --git a/app/src/main/res/drawable/baseline_video_file_24.xml b/app/src/main/res/drawable/baseline_video_file_24.xml
deleted file mode 100644
index ac30224..0000000
--- a/app/src/main/res/drawable/baseline_video_file_24.xml
+++ /dev/null
@@ -1,5 +0,0 @@
-
-      
-    
-    
-
diff --git a/app/src/main/res/drawable/image_border.xml b/app/src/main/res/drawable/image_border.xml
new file mode 100644
index 0000000..9a608d7
--- /dev/null
+++ b/app/src/main/res/drawable/image_border.xml
@@ -0,0 +1,6 @@
+
+
+    
+    
+    
+
diff --git a/app/src/main/res/drawable/image_border_selected.xml b/app/src/main/res/drawable/image_border_selected.xml
new file mode 100644
index 0000000..582728f
--- /dev/null
+++ b/app/src/main/res/drawable/image_border_selected.xml
@@ -0,0 +1,6 @@
+
+
+    
+    
+    
+
diff --git a/app/src/main/res/drawable/md_account_circle_24px.xml b/app/src/main/res/drawable/md_account_circle_24px.xml
new file mode 100644
index 0000000..fec33e7
--- /dev/null
+++ b/app/src/main/res/drawable/md_account_circle_24px.xml
@@ -0,0 +1,10 @@
+
+  
+
diff --git a/app/src/main/res/drawable/md_check_circle_24px.xml b/app/src/main/res/drawable/md_check_circle_24px.xml
new file mode 100644
index 0000000..728be3b
--- /dev/null
+++ b/app/src/main/res/drawable/md_check_circle_24px.xml
@@ -0,0 +1,10 @@
+
+  
+
diff --git a/app/src/main/res/drawable/md_code_blocks_24px.xml b/app/src/main/res/drawable/md_code_blocks_24px.xml
new file mode 100644
index 0000000..36fe496
--- /dev/null
+++ b/app/src/main/res/drawable/md_code_blocks_24px.xml
@@ -0,0 +1,10 @@
+
+  
+
diff --git a/app/src/main/res/drawable/md_csv_24px.xml b/app/src/main/res/drawable/md_csv_24px.xml
new file mode 100644
index 0000000..f85f8f0
--- /dev/null
+++ b/app/src/main/res/drawable/md_csv_24px.xml
@@ -0,0 +1,10 @@
+
+  
+
diff --git a/app/src/main/res/drawable/md_docs_24px.xml b/app/src/main/res/drawable/md_docs_24px.xml
new file mode 100644
index 0000000..c6f7bc7
--- /dev/null
+++ b/app/src/main/res/drawable/md_docs_24px.xml
@@ -0,0 +1,11 @@
+
+  
+
diff --git a/app/src/main/res/drawable/md_file_json_24px.xml b/app/src/main/res/drawable/md_file_json_24px.xml
new file mode 100644
index 0000000..e09dc3e
--- /dev/null
+++ b/app/src/main/res/drawable/md_file_json_24px.xml
@@ -0,0 +1,10 @@
+
+  
+
diff --git a/app/src/main/res/drawable/md_file_png_24px.xml b/app/src/main/res/drawable/md_file_png_24px.xml
new file mode 100644
index 0000000..7c8a307
--- /dev/null
+++ b/app/src/main/res/drawable/md_file_png_24px.xml
@@ -0,0 +1,10 @@
+
+  
+
diff --git a/app/src/main/res/drawable/md_fingerprint_24px.xml b/app/src/main/res/drawable/md_fingerprint_24px.xml
new file mode 100644
index 0000000..56ebe6b
--- /dev/null
+++ b/app/src/main/res/drawable/md_fingerprint_24px.xml
@@ -0,0 +1,10 @@
+
+  
+
diff --git a/app/src/main/res/drawable/md_gif_box_24px.xml b/app/src/main/res/drawable/md_gif_box_24px.xml
new file mode 100644
index 0000000..456a4f8
--- /dev/null
+++ b/app/src/main/res/drawable/md_gif_box_24px.xml
@@ -0,0 +1,10 @@
+
+  
+
diff --git a/app/src/main/res/drawable/md_image_24px.xml b/app/src/main/res/drawable/md_image_24px.xml
new file mode 100644
index 0000000..8f9ee7f
--- /dev/null
+++ b/app/src/main/res/drawable/md_image_24px.xml
@@ -0,0 +1,10 @@
+
+  
+
diff --git a/app/src/main/res/drawable/md_imagesmode_24px.xml b/app/src/main/res/drawable/md_imagesmode_24px.xml
new file mode 100644
index 0000000..676a265
--- /dev/null
+++ b/app/src/main/res/drawable/md_imagesmode_24px.xml
@@ -0,0 +1,10 @@
+
+  
+
diff --git a/app/src/main/res/drawable/md_key_24px.xml b/app/src/main/res/drawable/md_key_24px.xml
new file mode 100644
index 0000000..e0df275
--- /dev/null
+++ b/app/src/main/res/drawable/md_key_24px.xml
@@ -0,0 +1,10 @@
+
+  
+
diff --git a/app/src/main/res/drawable/md_link_24px.xml b/app/src/main/res/drawable/md_link_24px.xml
new file mode 100644
index 0000000..e8af9e1
--- /dev/null
+++ b/app/src/main/res/drawable/md_link_24px.xml
@@ -0,0 +1,10 @@
+
+  
+
diff --git a/app/src/main/res/drawable/md_login_24px.xml b/app/src/main/res/drawable/md_login_24px.xml
new file mode 100644
index 0000000..33f48c0
--- /dev/null
+++ b/app/src/main/res/drawable/md_login_24px.xml
@@ -0,0 +1,11 @@
+
+  
+
diff --git a/app/src/main/res/drawable/md_logout_24px.xml b/app/src/main/res/drawable/md_logout_24px.xml
new file mode 100644
index 0000000..77efdba
--- /dev/null
+++ b/app/src/main/res/drawable/md_logout_24px.xml
@@ -0,0 +1,11 @@
+
+  
+
diff --git a/app/src/main/res/drawable/md_menu_24px.xml b/app/src/main/res/drawable/md_menu_24px.xml
new file mode 100644
index 0000000..538d1cf
--- /dev/null
+++ b/app/src/main/res/drawable/md_menu_24px.xml
@@ -0,0 +1,10 @@
+
+  
+
diff --git a/app/src/main/res/drawable/md_music_note_24px.xml b/app/src/main/res/drawable/md_music_note_24px.xml
new file mode 100644
index 0000000..5929c80
--- /dev/null
+++ b/app/src/main/res/drawable/md_music_note_24px.xml
@@ -0,0 +1,10 @@
+
+  
+
diff --git a/app/src/main/res/drawable/md_open_in_new_24px.xml b/app/src/main/res/drawable/md_open_in_new_24px.xml
new file mode 100644
index 0000000..d7dabf4
--- /dev/null
+++ b/app/src/main/res/drawable/md_open_in_new_24px.xml
@@ -0,0 +1,11 @@
+
+  
+
diff --git a/app/src/main/res/drawable/md_person_24px.xml b/app/src/main/res/drawable/md_person_24px.xml
new file mode 100644
index 0000000..b77fac6
--- /dev/null
+++ b/app/src/main/res/drawable/md_person_24px.xml
@@ -0,0 +1,10 @@
+
+  
+
diff --git a/app/src/main/res/drawable/md_picture_as_pdf_24px.xml b/app/src/main/res/drawable/md_picture_as_pdf_24px.xml
new file mode 100644
index 0000000..86be6ef
--- /dev/null
+++ b/app/src/main/res/drawable/md_picture_as_pdf_24px.xml
@@ -0,0 +1,10 @@
+
+  
+
diff --git a/app/src/main/res/drawable/md_preview_24px.xml b/app/src/main/res/drawable/md_preview_24px.xml
new file mode 100644
index 0000000..abce12c
--- /dev/null
+++ b/app/src/main/res/drawable/md_preview_24px.xml
@@ -0,0 +1,10 @@
+
+  
+
diff --git a/app/src/main/res/drawable/md_settings_24px.xml b/app/src/main/res/drawable/md_settings_24px.xml
new file mode 100644
index 0000000..4bcd4aa
--- /dev/null
+++ b/app/src/main/res/drawable/md_settings_24px.xml
@@ -0,0 +1,10 @@
+
+  
+
diff --git a/app/src/main/res/drawable/md_share_24px.xml b/app/src/main/res/drawable/md_share_24px.xml
new file mode 100644
index 0000000..1550e6a
--- /dev/null
+++ b/app/src/main/res/drawable/md_share_24px.xml
@@ -0,0 +1,10 @@
+
+  
+
diff --git a/app/src/main/res/drawable/md_unknown_document_24px.xml b/app/src/main/res/drawable/md_unknown_document_24px.xml
new file mode 100644
index 0000000..3add22f
--- /dev/null
+++ b/app/src/main/res/drawable/md_unknown_document_24px.xml
@@ -0,0 +1,10 @@
+
+  
+
diff --git a/app/src/main/res/drawable/md_upload_24px.xml b/app/src/main/res/drawable/md_upload_24px.xml
new file mode 100644
index 0000000..203884b
--- /dev/null
+++ b/app/src/main/res/drawable/md_upload_24px.xml
@@ -0,0 +1,10 @@
+
+  
+
diff --git a/app/src/main/res/drawable/md_videocam_24px.xml b/app/src/main/res/drawable/md_videocam_24px.xml
new file mode 100644
index 0000000..ce6074f
--- /dev/null
+++ b/app/src/main/res/drawable/md_videocam_24px.xml
@@ -0,0 +1,11 @@
+
+  
+
diff --git a/app/src/main/res/layout-land/fragment_upload_multi.xml b/app/src/main/res/layout-land/fragment_upload_multi.xml
new file mode 100644
index 0000000..d8f7421
--- /dev/null
+++ b/app/src/main/res/layout-land/fragment_upload_multi.xml
@@ -0,0 +1,50 @@
+
+
+
+    
+