diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..d383ab4 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,35 @@ +name: Build the app + +on: [push] + +jobs: + build: + runs-on: ubuntu-latest + environment: Development + env: + ACRA_LOGIN: ${{ secrets.ACRARIUM_BASIC_AUTH_LOGIN }} + ACRA_PASS: ${{ secrets.ACRARIUM_BASIC_AUTH_PASSWORD }} + ACRA_URI: ${{ secrets.ACRARIUM_URI }} + steps: + - uses: actions/checkout@v2 + + - name: Set up JDK 11 + uses: actions/setup-java@v2 + with: + java-version: '11' + distribution: 'adopt' + + - name: Build Debug APK + run: ./gradlew assembleDebug + + - name: Upload Debug APK + uses: actions/upload-artifact@v2 + with: + name: debug-apk + path: ./app/build/outputs/apk/debug/app-debug.apk + + - name: Upload Release APK + uses: actions/upload-artifact@v2 + with: + name: release-apk + path: ./app/build/outputs/apk/release/app-release.apk diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8cacfbd --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +*.iml +.gradle +.idea +/local.properties +.DS_Store +/build +/captures +.externalNativeBuild +.cxx +local.properties diff --git a/app/.gitignore b/app/.gitignore new file mode 100644 index 0000000..796b96d --- /dev/null +++ b/app/.gitignore @@ -0,0 +1 @@ +/build diff --git a/app/build.gradle b/app/build.gradle new file mode 100644 index 0000000..b13a21d --- /dev/null +++ b/app/build.gradle @@ -0,0 +1,79 @@ +plugins { + id 'com.android.application' + id 'kotlin-android' + id 'kotlin-kapt' + id 'dagger.hilt.android.plugin' +} + +android { + compileSdk 32 + + defaultConfig { + applicationId "com.ark.globe" + minSdk 26 + targetSdk 32 + versionCode 1 + versionName "1.0" + def login = System.getenv("ACRA_LOGIN") ?: "" + def password = System.getenv("ACRA_PASS") ?: "" + def uri = System.getenv("ACRA_URI") ?: "" + buildConfigField "String", "ACRA_LOGIN", "\"$login\"" + buildConfigField "String", "ACRA_PASS", "\"$password\"" + buildConfigField "String", "ACRA_URI", "\"$uri\"" + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + } + + buildTypes { + release { + minifyEnabled false + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' + } + } + compileOptions { + sourceCompatibility JavaVersion.VERSION_11 + targetCompatibility JavaVersion.VERSION_11 + + } + kotlinOptions { + jvmTarget = JavaVersion.VERSION_11.toString() + } + buildFeatures { + viewBinding true + } +} + +dependencies { + implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" + implementation 'androidx.core:core-ktx:1.8.0' + implementation 'androidx.appcompat:appcompat:1.4.2' + implementation 'com.google.android.material:material:1.6.1' + implementation 'androidx.constraintlayout:constraintlayout:2.1.4' + implementation 'androidx.lifecycle:lifecycle-livedata-ktx:2.5.0' + implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.5.0' + implementation 'androidx.navigation:navigation-fragment-ktx:2.5.0' + implementation 'androidx.navigation:navigation-ui-ktx:2.5.0' + implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.4' + implementation 'androidx.room:room-runtime:2.4.2' + implementation 'androidx.room:room-ktx:2.4.2' + implementation 'androidx.preference:preference-ktx:1.2.0' + implementation 'com.android.volley:volley:1.2.1' + + implementation 'com.squareup.okhttp3:okhttp:4.10.0' + implementation 'com.squareup.retrofit2:retrofit:2.9.0' + implementation 'com.squareup.retrofit2:converter-scalars:2.9.0' + implementation 'com.squareup.retrofit2:converter-gson:2.4.0' + + implementation 'ch.acra:acra-http:5.9.5' + implementation 'ch.acra:acra-dialog:5.9.5' + + implementation "com.google.dagger:hilt-android:2.38.1" + kapt "com.google.dagger:hilt-compiler:2.38.1" + + implementation 'com.google.code.gson:gson:2.8.9' + implementation 'com.github.ark-builders:ark-filepicker:main-SNAPSHOT' + implementation 'androidx.preference:preference-ktx:1.2.0' + kapt 'androidx.room:room-compiler:2.4.2' + testImplementation 'junit:junit:4.13.2' + androidTestImplementation 'androidx.test.ext:junit:1.1.3' + androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0' +} \ No newline at end of file diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 0000000..9cbf6ac --- /dev/null +++ b/app/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class nameText to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file nameText. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/app/src/androidTest/kotlin/com/ark/globe/ExampleInstrumentedTest.kt b/app/src/androidTest/kotlin/com/ark/globe/ExampleInstrumentedTest.kt new file mode 100644 index 0000000..ee19c26 --- /dev/null +++ b/app/src/androidTest/kotlin/com/ark/globe/ExampleInstrumentedTest.kt @@ -0,0 +1,24 @@ +package com.ark.globe + +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.ext.junit.runners.AndroidJUnit4 + +import org.junit.Test +import org.junit.runner.RunWith + +import org.junit.Assert.* + +/** + * Instrumented test, which will execute on an Android device. + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +@RunWith(AndroidJUnit4::class) +class ExampleInstrumentedTest { + @Test + fun useAppContext() { + // Context of the app under test. + val appContext = InstrumentationRegistry.getInstrumentation().targetContext + assertEquals("com.ark.globe", appContext.packageName) + } +} \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..c6ba2c8 --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,35 @@ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/ark_globe-playstore.png b/app/src/main/ark_globe-playstore.png new file mode 100644 index 0000000..07a63f7 Binary files /dev/null and b/app/src/main/ark_globe-playstore.png differ diff --git a/app/src/main/kotlin/com/ark/globe/App.kt b/app/src/main/kotlin/com/ark/globe/App.kt new file mode 100644 index 0000000..f9ad65f --- /dev/null +++ b/app/src/main/kotlin/com/ark/globe/App.kt @@ -0,0 +1,36 @@ +package com.ark.globe + +import android.app.Application +import android.content.Context +import dagger.hilt.android.HiltAndroidApp +import org.acra.config.dialog +import org.acra.config.httpSender +import org.acra.data.StringFormat +import org.acra.ktx.initAcra +import org.acra.sender.HttpSender + +@HiltAndroidApp +class App: Application() { + + override fun attachBaseContext(baseContext: Context){ + super.attachBaseContext(baseContext) + initAcra { + buildConfigClass = BuildConfig::class.java + reportFormat = StringFormat.JSON + + dialog{ + text = getString(R.string.crash_dialog_desc) + title = getString(R.string.crash_dialog_title) + commentPrompt = getString(R.string.crash_dialog_comment) + } + + httpSender { + uri = BuildConfig.ACRA_URI + basicAuthLogin = BuildConfig.ACRA_LOGIN + basicAuthPassword = BuildConfig.ACRA_PASS + httpMethod = HttpSender.Method.POST + } + } + } + +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/ark/globe/activities/MainActivity.kt b/app/src/main/kotlin/com/ark/globe/activities/MainActivity.kt new file mode 100644 index 0000000..3e2711d --- /dev/null +++ b/app/src/main/kotlin/com/ark/globe/activities/MainActivity.kt @@ -0,0 +1,97 @@ +package com.ark.globe.activities + +import android.os.Bundle +import android.view.Menu +import android.view.MenuItem +import androidx.activity.result.contract.ActivityResultContracts +import androidx.appcompat.app.AppCompatActivity +import com.ark.globe.R +import com.ark.globe.contracts.PermissionContract +import com.ark.globe.databinding.ActivityMainBinding +import com.ark.globe.filehandling.FilePicker +import com.ark.globe.fragments.Settings +import com.ark.globe.fragments.locations.LocationsFragment +import com.ark.globe.preferences.GlobePreferences +import space.taran.arkfilepicker.onArkPathPicked + +class MainActivity: AppCompatActivity() { + + private lateinit var binding: ActivityMainBinding + private val locationsFragment = LocationsFragment() + private val settingsFragment = Settings() + + init{ + FilePicker.readPermLauncher_SDK_R = registerForActivityResult(PermissionContract()){ + if(FilePicker.isReadPermissionGranted(this)) + FilePicker.show() + else{ + FilePicker.permissionDeniedError(this) + finish() + } + } + + FilePicker.readPermLauncher = registerForActivityResult(ActivityResultContracts.RequestPermission()){ isGranted -> + if(isGranted){ + FilePicker.show() + } + else{ + FilePicker.permissionDeniedError(this) + finish() + } + } + } + + override fun onCreate(savedInstanceState: Bundle?){ + super.onCreate(savedInstanceState) + delegate.localNightMode = GlobePreferences.getInstance(this).getNightMode() + binding = ActivityMainBinding.inflate(layoutInflater) + setContentView(binding.root) + setSupportActionBar(binding.toolbar) + binding.toolbar.setNavigationOnClickListener{ + onBackPressed() + } + + locationsFragment.sendIntent(intent) + + if(GlobePreferences.getInstance(this).getPath() == null) + FilePicker.show(this, supportFragmentManager) + + if(savedInstanceState == null){ + supportFragmentManager.beginTransaction().apply { + add(R.id.container, locationsFragment, LocationsFragment.TAG) + commit() + } + } + + supportFragmentManager.onArkPathPicked(this) { + val globePrefs = GlobePreferences.getInstance(this) + globePrefs.storePath("$it") + } + } + + override fun onCreateOptionsMenu(menu: Menu): Boolean { + menuInflater.inflate(R.menu.main_menu, menu) + return true + } + + override fun onOptionsItemSelected(item: MenuItem): Boolean { + when(item.itemId){ + R.id.settings -> { + supportFragmentManager.beginTransaction().apply { + val backStackName = settingsFragment.javaClass.name + val popBackStack = supportFragmentManager.popBackStackImmediate(backStackName, 0) + if(!popBackStack) { + replace(R.id.container, settingsFragment, Settings.TAG) + addToBackStack(backStackName) + commit() + } + else{ + show(settingsFragment) + commit() + } + } + } + } + return true + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/ark/globe/adapters/LocationsAdapter.kt b/app/src/main/kotlin/com/ark/globe/adapters/LocationsAdapter.kt new file mode 100644 index 0000000..da10ee6 --- /dev/null +++ b/app/src/main/kotlin/com/ark/globe/adapters/LocationsAdapter.kt @@ -0,0 +1,40 @@ +package com.ark.globe.adapters + +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.TextView +import androidx.recyclerview.widget.RecyclerView +import com.ark.globe.R +import com.ark.globe.coordinates.Location + +class LocationsAdapter(private val locations: List): + RecyclerView.Adapter() { + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): LocationsViewHolder { + val itemView = LayoutInflater.from(parent.context).inflate(R.layout.coordinate_view, parent, false) + return LocationsViewHolder(itemView) + } + + override fun getItemCount(): Int { + return locations.size + } + + override fun onBindViewHolder( + holder: LocationsViewHolder, + position: Int + ) { + val coordinates = "${locations[position].coordinates.latitude}, ${locations[position].coordinates.longitude}" + holder.apply{ + locationName.text = locations[position].name + locationDesc.text = locations[position].description + this.coordinates.text = coordinates + } + } + + class LocationsViewHolder(itemView: View): RecyclerView.ViewHolder(itemView){ + val locationName: TextView = itemView.findViewById(R.id.locationName) + val locationDesc: TextView = itemView.findViewById(R.id.locationDesc) + val coordinates: TextView = itemView.findViewById(R.id.coordinates) + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/ark/globe/contracts/PermissionContract.kt b/app/src/main/kotlin/com/ark/globe/contracts/PermissionContract.kt new file mode 100644 index 0000000..a06b816 --- /dev/null +++ b/app/src/main/kotlin/com/ark/globe/contracts/PermissionContract.kt @@ -0,0 +1,15 @@ +package com.ark.globe.contracts + +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.provider.Settings +import androidx.activity.result.contract.ActivityResultContract + +class PermissionContract: ActivityResultContract() { + override fun createIntent(context: Context, input: String): Intent { + return Intent(Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION, Uri.parse(input)) + } + + override fun parseResult(resultCode: Int, intent: Intent?) = Unit +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/ark/globe/coordinates/Coordinates.kt b/app/src/main/kotlin/com/ark/globe/coordinates/Coordinates.kt new file mode 100644 index 0000000..6077cfd --- /dev/null +++ b/app/src/main/kotlin/com/ark/globe/coordinates/Coordinates.kt @@ -0,0 +1,6 @@ +package com.ark.globe.coordinates + +data class Coordinates( + var latitude: Double? = null, + var longitude: Double? = null +) \ No newline at end of file diff --git a/app/src/main/kotlin/com/ark/globe/coordinates/Location.kt b/app/src/main/kotlin/com/ark/globe/coordinates/Location.kt new file mode 100644 index 0000000..827c486 --- /dev/null +++ b/app/src/main/kotlin/com/ark/globe/coordinates/Location.kt @@ -0,0 +1,7 @@ +package com.ark.globe.coordinates + +data class Location ( + var name: String, + var description: String, + var coordinates: Coordinates + ) \ No newline at end of file diff --git a/app/src/main/kotlin/com/ark/globe/filehandling/FilePicker.kt b/app/src/main/kotlin/com/ark/globe/filehandling/FilePicker.kt new file mode 100644 index 0000000..5dac401 --- /dev/null +++ b/app/src/main/kotlin/com/ark/globe/filehandling/FilePicker.kt @@ -0,0 +1,68 @@ +package com.ark.globe.filehandling + +import android.Manifest +import android.content.Context +import android.content.pm.PackageManager +import android.os.Build +import android.os.Environment +import android.widget.Toast +import androidx.activity.result.ActivityResultLauncher +import androidx.appcompat.app.AppCompatActivity +import androidx.core.content.ContextCompat +import androidx.fragment.app.FragmentManager +import com.ark.globe.BuildConfig +import com.ark.globe.R +import space.taran.arkfilepicker.ArkFilePickerConfig +import space.taran.arkfilepicker.ArkFilePickerFragment +import space.taran.arkfilepicker.ArkFilePickerMode + +class FilePicker private constructor(){ + companion object{ + + private const val TAG = "file_picker" + private var fragmentManager: FragmentManager? = null + var readPermLauncher: ActivityResultLauncher? = null + var readPermLauncher_SDK_R: ActivityResultLauncher? = null + + fun show() { + ArkFilePickerFragment.newInstance(getFilePickerConfig()).show(fragmentManager!!, TAG) + } + + fun show(activity: AppCompatActivity, fragmentManager: FragmentManager){ + this.fragmentManager = fragmentManager + if(isReadPermissionGranted(activity)){ + show() + } + else askForReadPermissions() + } + + fun isReadPermissionGranted(activity: AppCompatActivity): Boolean{ + return if(Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) + Environment.isExternalStorageManager() + else{ + ContextCompat.checkSelfPermission(activity, Manifest.permission.WRITE_EXTERNAL_STORAGE) == + PackageManager.PERMISSION_GRANTED + } + } + + private fun askForReadPermissions() { + if(Build.VERSION.SDK_INT >= Build.VERSION_CODES.R){ + val packageUri ="package:" + BuildConfig.APPLICATION_ID + readPermLauncher_SDK_R?.launch(packageUri) + } + else{ + readPermLauncher?.launch(Manifest.permission.WRITE_EXTERNAL_STORAGE) + } + } + + fun permissionDeniedError(context: Context){ + Toast.makeText(context, context.getString(R.string.no_file_access), Toast.LENGTH_SHORT).show() + } + + private fun getFilePickerConfig() = ArkFilePickerConfig( + mode = ArkFilePickerMode.FOLDER, + titleStringId = R.string.file_picker_title, + pickButtonStringId = R.string.select + ) + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/ark/globe/fragments/Settings.kt b/app/src/main/kotlin/com/ark/globe/fragments/Settings.kt new file mode 100644 index 0000000..30c97b9 --- /dev/null +++ b/app/src/main/kotlin/com/ark/globe/fragments/Settings.kt @@ -0,0 +1,66 @@ +package com.ark.globe.fragments + +import android.os.Bundle +import android.view.View +import androidx.appcompat.app.AppCompatActivity +import androidx.appcompat.app.AppCompatDelegate +import androidx.fragment.app.activityViewModels +import androidx.preference.PreferenceFragmentCompat +import androidx.preference.SwitchPreferenceCompat +import com.ark.globe.R +import com.ark.globe.filehandling.FilePicker +import com.ark.globe.fragments.locations.LocationsViewModel +import com.ark.globe.fragments.ui.PathPreference +import com.ark.globe.jsonprocess.JSONFile +import com.ark.globe.preferences.GlobePreferences +import space.taran.arkfilepicker.onArkPathPicked + +class Settings : PreferenceFragmentCompat() { + + private val activity: AppCompatActivity by lazy { + requireActivity() as AppCompatActivity + } + + private val lViewModel: LocationsViewModel by activityViewModels() + + override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { + activity.title = getString(R.string.settings) + setPreferencesFromResource(R.xml.root_preferences, rootKey) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + val pathKEY = getString(R.string.path_pref_key) + val nightModeKEY = getString(R.string.dark_mode_pref_key) + val pathPref: PathPreference? = findPreference(pathKEY) + val darkModePref: SwitchPreferenceCompat? = findPreference(nightModeKEY) + val supportActionBar = activity.supportActionBar + supportActionBar?.setDisplayHomeAsUpEnabled(true) + + pathPref?.setOnPreferenceClickListener { + FilePicker.show(requireActivity() as AppCompatActivity, parentFragmentManager) + true + } + + darkModePref?.setOnPreferenceChangeListener { preference, _ -> + preference as SwitchPreferenceCompat + val nightMode = if(preference.isChecked) + AppCompatDelegate.MODE_NIGHT_NO + else AppCompatDelegate.MODE_NIGHT_YES + GlobePreferences.getInstance(requireContext()).storeNightMode(nightMode) + (requireActivity() as AppCompatActivity).delegate.localNightMode = nightMode + true + } + + parentFragmentManager.onArkPathPicked(viewLifecycleOwner) { + val globePrefs = GlobePreferences.getInstance(requireContext()) + globePrefs.storePath("$it") + lViewModel.readJsonLocations(requireContext()) + pathPref?.setPath(globePrefs.getPath()) + } + } + + companion object{ + const val TAG = "Settings" + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/ark/globe/fragments/locations/LocationsFragment.kt b/app/src/main/kotlin/com/ark/globe/fragments/locations/LocationsFragment.kt new file mode 100644 index 0000000..be21aa2 --- /dev/null +++ b/app/src/main/kotlin/com/ark/globe/fragments/locations/LocationsFragment.kt @@ -0,0 +1,146 @@ +package com.ark.globe.fragments.locations + +import android.content.Intent +import android.os.Bundle +import android.text.Editable +import android.text.TextWatcher +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.Button +import android.widget.EditText +import android.widget.Toast +import androidx.appcompat.app.AppCompatActivity +import androidx.fragment.app.Fragment +import androidx.fragment.app.activityViewModels +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import com.ark.globe.R +import com.ark.globe.adapters.LocationsAdapter +import com.ark.globe.coordinates.Coordinates +import com.ark.globe.coordinates.Location +import com.ark.globe.repositories.Repository + +class LocationsFragment: Fragment() { + + private val activity: AppCompatActivity by lazy{ + requireActivity() as AppCompatActivity + } + private val lViewModel: LocationsViewModel by activityViewModels() + private var adapter: LocationsAdapter? = null + private var intent: Intent? = null + private var longitude: EditText? = null + private var latitude: EditText? = null + + private val urlChangeListener = object : TextWatcher { + override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) { + if (s != null && s.isNotEmpty()) { + lViewModel.extractCoordinates(s.toString()){ + updateCoordinates(it) + } + } + } + + override fun afterTextChanged(s: Editable?) = Unit + + override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) = Unit + } + + fun sendIntent(intent: Intent){ + this.intent = intent + } + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, + savedInstanceState: Bundle?): View { + activity.supportActionBar?.setDisplayHomeAsUpEnabled(false) + activity.title = getString(R.string.app_name) + lViewModel.repository = Repository() + return inflater.inflate(R.layout.location_input, container, false) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + val locationName: EditText = view.findViewById(R.id.locationName) + val locationDesc: EditText = view.findViewById(R.id.locationDesc) + val urlText: EditText = view.findViewById(R.id.urlText) + val addButton: Button = view.findViewById(R.id.addButton) + val layoutManager = LinearLayoutManager(requireContext()) + val recyclerView: RecyclerView = view.findViewById(R.id.recycler_view) + longitude = view.findViewById(R.id.longitude) + latitude = view.findViewById(R.id.latitude) + + lViewModel.apply { + locations.observe(viewLifecycleOwner) { + adapter = LocationsAdapter(it) + recyclerView.apply { + this.layoutManager = layoutManager + this.adapter = this@LocationsFragment.adapter + } + } + readJsonLocations(requireContext()) + } + + urlText.addTextChangedListener(urlChangeListener) + + if(intent != null) { + val data = intent?.getStringExtra(Intent.EXTRA_TEXT) + urlText.setText(lViewModel.getValidUrl(data)) + } + + addButton.setOnClickListener { + val mName = locationName.text.toString() + val mDescription = locationDesc.text.toString() + val mLatitude = latitude?.text.toString() + val mLongitude = longitude?.text.toString() + if(mName.isNotEmpty()) { + if (mLongitude.isNotEmpty()) { + if (mLatitude.isNotEmpty()) { + val coordinates = Coordinates( + mLatitude.toDouble(), + mLongitude.toDouble() + ) + val location = Location(mName, mDescription, coordinates) + + lViewModel.saveLocation(requireContext(), location) + + lViewModel.addLocation(location) + + adapter?.notifyDataSetChanged() + lViewModel.writeCoordinates(null) + locationName.text = null + locationDesc.text = null + urlText.text = null + longitude?.text = null + latitude?.text = null + if(intent != null) + intent = null + locationName.requestFocus() + } else coordinateError(getString(R.string._longitude)) + } else coordinateError(getString(R.string._latitude)) + } else descriptionError(getString(R.string._location_name)) + } + } + + private fun updateCoordinates(coordinates: Coordinates?){ + if (coordinates != null) { + latitude?.setText(coordinates.latitude.toString()) + longitude?.setText(coordinates.longitude.toString()) + } + else{ + latitude?.text = null + longitude?.text = null + } + } + + private fun coordinateError(missingValue: String){ + Toast.makeText(requireContext(), getString(R.string.coordinate_error, missingValue), Toast.LENGTH_SHORT).show() + } + + private fun descriptionError(missingValue: String){ + Toast.makeText(requireContext(), getString(R.string.description_error, missingValue), Toast.LENGTH_SHORT).show() + } + + companion object { + const val TAG = "Locations Fragment" + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/ark/globe/fragments/locations/LocationsViewModel.kt b/app/src/main/kotlin/com/ark/globe/fragments/locations/LocationsViewModel.kt new file mode 100644 index 0000000..5cdb8e7 --- /dev/null +++ b/app/src/main/kotlin/com/ark/globe/fragments/locations/LocationsViewModel.kt @@ -0,0 +1,74 @@ +package com.ark.globe.fragments.locations + +import android.content.Context +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.ark.globe.coordinates.Coordinates +import com.ark.globe.coordinates.Location +import com.ark.globe.repositories.Repository +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import javax.inject.Inject + +class LocationsViewModel: ViewModel() { + + private val iODispatcher = Dispatchers.IO + lateinit var repository: Repository + private val locationsList = mutableListOf() + + private val _locations: MutableLiveData> by lazy { + MutableLiveData>() + } + val locations: LiveData> = _locations + + private val _coordinates: MutableLiveData by lazy { + MutableLiveData() + } + + val coordinates: LiveData = _coordinates + + fun writeCoordinates(coordinates: Coordinates?) { + _coordinates.value = coordinates + } + + fun addLocation(location: Location){ + locationsList.add(location) + _locations.value = locationsList + } + + fun extractCoordinates(url: String?, coordinates: (Coordinates?) -> Unit){ + viewModelScope.launch { + withContext(iODispatcher){ + coordinates(repository.extractCoordinates(url)) + } + } + } + + fun getValidUrl(urlStr: String?) = repository.getValidURL(urlStr) + + fun saveLocation(context: Context, location: Location){ + viewModelScope.launch { + withContext(iODispatcher) { + repository.saveLocation(context, location) + } + } + } + + fun readJsonLocations(context: Context){ + if(locationsList.isNotEmpty()) + locationsList.clear() + viewModelScope.launch { + withContext(iODispatcher){ + locationsList.addAll(repository.readJsonLocations(context)) + } + } + _locations.postValue(locationsList) + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/ark/globe/fragments/ui/PathPreference.kt b/app/src/main/kotlin/com/ark/globe/fragments/ui/PathPreference.kt new file mode 100644 index 0000000..92f9630 --- /dev/null +++ b/app/src/main/kotlin/com/ark/globe/fragments/ui/PathPreference.kt @@ -0,0 +1,33 @@ +package com.ark.globe.fragments.ui + +import android.content.Context +import android.util.AttributeSet +import android.widget.TextView +import androidx.preference.Preference +import androidx.preference.PreferenceViewHolder +import com.ark.globe.R +import com.ark.globe.preferences.GlobePreferences +import java.io.File +import java.nio.file.Path + +class PathPreference(context: Context, attrs: AttributeSet): Preference(context, attrs) { + private var path: TextView? = null + private var title: TextView? = null + + fun setPath(path: String?){ + if(path != null) + this.path?.text = path + } + + fun setTitle(title: String?){ + if(title != null) + this.title?.text = title + } + + override fun onBindViewHolder(holder: PreferenceViewHolder) { + super.onBindViewHolder(holder) + title = holder.findViewById(R.id.title) as TextView + path = holder.findViewById(R.id.pathValue) as TextView + setPath(GlobePreferences.getInstance(context).getPath()) + } +} diff --git a/app/src/main/kotlin/com/ark/globe/jsonprocess/JSONFile.kt b/app/src/main/kotlin/com/ark/globe/jsonprocess/JSONFile.kt new file mode 100644 index 0000000..b543b55 --- /dev/null +++ b/app/src/main/kotlin/com/ark/globe/jsonprocess/JSONFile.kt @@ -0,0 +1,99 @@ +package com.ark.globe.jsonprocess + +import android.content.Context +import android.util.Log +import com.ark.globe.coordinates.Location +import com.ark.globe.preferences.GlobePreferences +import java.io.* +import java.nio.file.Files +import java.nio.file.Path +import kotlin.io.path.extension + +class JSONFile { + companion object{ + private const val JSON_EXT = "json" + private const val FILE_NAME = "Location " + + private fun createJsonFile(path: Path?, jsonString: String){ + var numberOfFiles = 0 + if(path != null) { + Files.list(path).forEach { + if (it.fileName.extension == JSON_EXT) { + numberOfFiles++ + } + } + val file = path.toFile() + val jsonFile = File(file, "$FILE_NAME$numberOfFiles.$JSON_EXT") + if (!jsonFile.exists()) { + try { + val fileWriter = FileWriter(jsonFile) + val bufferedWriter = BufferedWriter(fileWriter) + with(bufferedWriter) { + write(jsonString) + close() + } + } catch (e: Exception) { + e.printStackTrace() + } + } + } + } + + fun saveLocation(context: Context, location: Location?){ + if (location != null) { + val path = getPath(context) + if (path != null) { + createJsonFile( + path, + JSONParser.parseLocationToJSON(location) + ) + } + } + } + + fun readJsonLocations(context: Context):List{ + var numberOfFiles = 0 + val locations = mutableListOf() + val path = getPath(context) + if (path != null) { + Files.list(path). forEach{ filePath -> + if(filePath.fileName.extension == JSON_EXT) { + try { + val jsonFile = filePath.toFile() + val fileReader = FileReader(jsonFile) + val bufferedReader = BufferedReader(fileReader) + val jsonLocation = StringBuilder() + with(bufferedReader) { + forEachLine { + jsonLocation.append(it) + } + locations.add(JSONParser.parseFromJsonToLocation(jsonLocation.toString())) + numberOfFiles += 1 + Log.d("File ${numberOfFiles}:", jsonLocation.toString()) + close() + } + } catch (e: Exception) { + e.printStackTrace() + } + } + } + } + return locations + } + + private fun getPath(context: Context):Path?{ + val prefs = GlobePreferences.getInstance(context) + val pathString = prefs.getPath() + var path: Path? = null + try { + val file = File(pathString!!) + file.mkdir() + path = file.toPath() + } + catch(e: Exception) { + e.printStackTrace() + } + return path + } + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/ark/globe/jsonprocess/JSONParser.kt b/app/src/main/kotlin/com/ark/globe/jsonprocess/JSONParser.kt new file mode 100644 index 0000000..0045d58 --- /dev/null +++ b/app/src/main/kotlin/com/ark/globe/jsonprocess/JSONParser.kt @@ -0,0 +1,19 @@ +package com.ark.globe.jsonprocess + +import com.ark.globe.coordinates.Location +import com.google.gson.Gson + +class JSONParser { + companion object { + + private val gson = Gson() + + fun parseLocationToJSON(location: Location): String { + return gson.toJson(location, Location::class.java) + } + + fun parseFromJsonToLocation(string: String): Location{ + return gson.fromJson(string, Location::class.java) + } + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/ark/globe/preferences/GlobePreferences.kt b/app/src/main/kotlin/com/ark/globe/preferences/GlobePreferences.kt new file mode 100644 index 0000000..bb0d0c6 --- /dev/null +++ b/app/src/main/kotlin/com/ark/globe/preferences/GlobePreferences.kt @@ -0,0 +1,43 @@ +package com.ark.globe.preferences + +import android.content.Context +import android.content.Context.MODE_PRIVATE +import androidx.appcompat.app.AppCompatDelegate +import com.ark.globe.R + +class GlobePreferences private constructor(context: Context) { + private val sharedPreferences = context.getSharedPreferences(GLOBE_PREFS, MODE_PRIVATE) + private val editor = sharedPreferences.edit() + private val pathKEY = context.getString(R.string.path_pref_key) + private val nightModeKEY = context.getString(R.string.dark_mode_pref_key) + + fun storePath(path: String){ + with(editor){ + putString(pathKEY, path) + apply() + } + } + + fun getPath() = sharedPreferences.getString(pathKEY, null) + + fun storeNightMode(nightMode: Int){ + with(editor){ + putInt(nightModeKEY, nightMode) + apply() + } + } + + fun getNightMode() = sharedPreferences.getInt(nightModeKEY, AppCompatDelegate.MODE_NIGHT_NO) + + companion object{ + private const val GLOBE_PREFS = "globe_prefs" + private var instance: GlobePreferences? = null + + fun getInstance(context: Context): GlobePreferences{ + if(instance == null){ + instance = GlobePreferences((context)) + } + return instance!! + } + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/ark/globe/repositories/Repository.kt b/app/src/main/kotlin/com/ark/globe/repositories/Repository.kt new file mode 100644 index 0000000..e09a2a7 --- /dev/null +++ b/app/src/main/kotlin/com/ark/globe/repositories/Repository.kt @@ -0,0 +1,180 @@ +package com.ark.globe.repositories + +import android.content.Context +import android.net.Uri +import android.util.Log +import com.ark.globe.BuildConfig +import com.ark.globe.coordinates.Coordinates +import com.ark.globe.coordinates.Location +import com.ark.globe.jsonprocess.JSONFile +import okhttp3.OkHttpClient +import okhttp3.Request +import java.io.BufferedReader +import java.net.HttpURLConnection + +class Repository { + private var coordinatesString = "" + private var latitude: String? = null + private var longitude: String? = null + + fun extractCoordinates(url: String?): Coordinates?{ + var coordinates: Coordinates? = null + val validUrl = getValidURL(url) + Log.d("Valid URL", validUrl.toString()) + latitude = null + longitude = null + if(validUrl != null && (validUrl.contains(GOOGLE_MAPS) || validUrl.contains(GOOGLE_MAPS_SHORT))) + parseGoogleMapsLinks(validUrl) + + if (validUrl != null && (validUrl.contains(OPEN_STREET_MAP) + || validUrl.contains(OPEN_STREET_MAP_SHORT))) + //openstreetmap link processing + //https://www.openstreetmap.org/#map=8/-13.397/34.31 + parseOpenStreetMapLinks(validUrl) + + if (validUrl != null && validUrl.contains(OSM_AND_NET)) + //osmand.net link processing + //https://osmand.net/go?lat=36.54151&lon=31.99651&z=17 + parseOSMANDNETLinks(validUrl) + + if (latitude?.toDoubleOrNull() != null && longitude?.toDoubleOrNull() != null) { + coordinates = Coordinates(latitude?.toDouble(), longitude?.toDouble()) + } + Log.d("Parsed Coordinates", "$latitude, $longitude") + return coordinates + } + + private fun parseGoogleMapsLinks(url: String?){ + if(url != null) { + if (url.contains(GOOGLE_MAPS)) { + val uri = Uri.parse(url) + val coordinates = uri.getQueryParameter("q") + val coords = coordinates?.split(",") + if(coords != null) { + latitude = coords[0] + longitude = coords[1] + } + } else { + loadUrl(url) + } + } + } + + private fun parseOpenStreetMapLinks(url: String?){ + if(url != null && url.contains(OPEN_STREET_MAP)){ + val uri = Uri.parse(url) + val frag = uri.fragment + if(frag != null) + coordinatesString = frag + val splitString = coordinatesString.split(DELIMITER_3) + latitude = splitString[1] + longitude = splitString[2] + } + if (url != null && url.contains(OPEN_STREET_MAP_SHORT)) + loadUrl(url) + } + + private fun parseOSMANDNETLinks(url: String?){ + val uri = Uri.parse(url) + latitude = uri.getQueryParameter("lat") + longitude = uri.getQueryParameter("lon") + } + + fun getValidURL(urlStr: String?): String?{ + return if(urlStr != null){ + if(urlStr.contains(URL_PROTOCOL_2)) { + URL_PROTOCOL_2 + urlStr.substringAfter(URL_PROTOCOL_2) + } + else if(urlStr.contains(URL_PROTOCOL_1)){ + URL_PROTOCOL_1 + urlStr.substringAfter(URL_PROTOCOL_1) + } + else null + } + else null + } + + private fun loadUrl(url: String?){ + val client: OkHttpClient + val request: Request + var response: okhttp3.Response? = null + if(url != null) { + client = OkHttpClient.Builder().build() + request = Request.Builder() + .url(url) + .build() + try { + response = client.newCall(request).execute() + + val bReader = BufferedReader(response.body?.charStream()) + val status = response.code + var actualUrl = "" + Log.d("Status", status.toString()) + var isParsed = false + if (status == HttpURLConnection.HTTP_OK) { + actualUrl = response.request.url.toString() + Log.d("Actual URL", actualUrl) + bReader.forEachLine { + //println("Line: $it") + if (it.contains("/@")) + Log.d("Redirect", it.substringAfter("/@")) + isParsed = parseGoogleCoordinates(it) + if (isParsed) { + Log.d("Is Parsed", isParsed.toString()) + return@forEachLine + } + } + } +// https://maps.app.goo.gl/XTudbauXz5ZdfXAWA +// https://www.google.com/maps?q=Google+Building+1600,+1600+Plymouth+St,+Mountain+View,+CA+94043,+USA&ftid=0x808fba002c047109:0x8a6e9df8c478269&hl=en&gl=us&g_ep=GAA%3D&shorturl=1https://www.google.com/maps?q=Google+Building+1600,+1600+Plymouth+St,+Mountain+View,+CA+94043,+USA&ftid=0x808fba002c047109:0x8a6e9df8c478269&hl=en&gl=us&g_ep=GAA%3D&shorturl=1https://www.google.com/maps?q=Google+Building+1600,+1600+Plymouth+St,+Mountain+View,+CA+94043,+USA&ftid=0x808fba002c047109:0x8a6e9df8c478269&hl=en&gl=us&g_ep=GAA%3D&shorturl=1 + if(!isParsed) { + Log.d("Is parsed", isParsed.toString()) + if (actualUrl.contains(GOOGLE_MAPS)) { + parseGoogleMapsLinks(actualUrl) + return + } + + if (actualUrl.contains(OPEN_STREET_MAP)) { + parseOpenStreetMapLinks(actualUrl) + return + } + } + } catch (e: Exception) { + e.printStackTrace() + } finally { + response?.close() + } + } + } + + private fun parseGoogleCoordinates(line: String): Boolean{ + return if(line.contains(DELIMITER_1)){ + coordinatesString = line.substringAfter(DELIMITER_1).substringBefore(DELIMITER_3) + val coords = coordinatesString.split(DELIMITER_2) + latitude = coords[0] + longitude = coords[1] + true + } + else false + } + + fun saveLocation(context: Context, location: Location?){ + JSONFile.saveLocation(context, location) + } + + fun readJsonLocations(context: Context): List{ + return JSONFile.readJsonLocations(context) + } + + companion object{ + private const val GOOGLE_MAPS = ".google." + private const val GOOGLE_MAPS_SHORT = "goo.gl" + private const val OPEN_STREET_MAP = "www.openstreetmap.org" + private const val OPEN_STREET_MAP_SHORT = "osm.org" + private const val OSM_AND_NET = "osmand.net" + private const val URL_PROTOCOL_1 = "http" + private const val URL_PROTOCOL_2 = "https" + private const val DELIMITER_1 = "/@" + private const val DELIMITER_2 = "," + private const val DELIMITER_3 = "/" + } +} \ No newline at end of file diff --git a/app/src/main/res/drawable/ark_globe_foreground.xml b/app/src/main/res/drawable/ark_globe_foreground.xml new file mode 100644 index 0000000..99b0986 --- /dev/null +++ b/app/src/main/res/drawable/ark_globe_foreground.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/app/src/main/res/drawable/location.xml b/app/src/main/res/drawable/location.xml new file mode 100644 index 0000000..5d58adc --- /dev/null +++ b/app/src/main/res/drawable/location.xml @@ -0,0 +1,6 @@ + + + diff --git a/app/src/main/res/drawable/save.xml b/app/src/main/res/drawable/save.xml new file mode 100644 index 0000000..82070aa --- /dev/null +++ b/app/src/main/res/drawable/save.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/settings.xml b/app/src/main/res/drawable/settings.xml new file mode 100644 index 0000000..74081ac --- /dev/null +++ b/app/src/main/res/drawable/settings.xml @@ -0,0 +1,30 @@ + + + diff --git a/app/src/main/res/drawable/url_text_background.xml b/app/src/main/res/drawable/url_text_background.xml new file mode 100644 index 0000000..a9c8efe --- /dev/null +++ b/app/src/main/res/drawable/url_text_background.xml @@ -0,0 +1,10 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml new file mode 100644 index 0000000..e54b352 --- /dev/null +++ b/app/src/main/res/layout/activity_main.xml @@ -0,0 +1,28 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/coordinate_view.xml b/app/src/main/res/layout/coordinate_view.xml new file mode 100644 index 0000000..4e85b3e --- /dev/null +++ b/app/src/main/res/layout/coordinate_view.xml @@ -0,0 +1,51 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/location_input.xml b/app/src/main/res/layout/location_input.xml new file mode 100644 index 0000000..e51e3f7 --- /dev/null +++ b/app/src/main/res/layout/location_input.xml @@ -0,0 +1,131 @@ + + + + + + + + + + + + + + + + + + +