Commit 0b977218 authored by Mark Murphy's avatar Mark Murphy

new sample apps

parent f439bf19
apply plugin: 'com.android.application'
apply plugin: 'kotlin-android'
apply plugin: 'kotlin-android-extensions'
android {
compileSdkVersion 'android-Q'
defaultConfig {
applicationId "com.commonsware.android.conferencevideos"
minSdkVersion 24
targetSdkVersion 28
versionCode 1
versionName "1.0"
}
buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
}
dataBinding {
enabled = true
}
compileOptions {
sourceCompatibility 1.8
targetCompatibility 1.8
}
}
dependencies {
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutines_version"
implementation 'androidx.appcompat:appcompat:1.0.2'
implementation 'androidx.core:core-ktx:1.0.2'
implementation 'androidx.constraintlayout:constraintlayout:1.1.3'
implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycle_version"
implementation "androidx.recyclerview:recyclerview:$recyclerview_version"
implementation "org.koin:koin-core:$koin_version"
implementation "org.koin:koin-android:$koin_version"
implementation "org.koin:koin-androidx-viewmodel:$koin_version"
implementation "com.squareup.okhttp3:okhttp:3.14.1"
}
# 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 name 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 name.
#-renamesourcefileattribute SourceFile
<?xml version="1.0" encoding="utf-8"?>
<manifest package="com.commonsware.android.conferencevideos"
xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<application
android:name=".KoinApp"
android:allowBackup="false"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/AppTheme">
<activity android:name=".MainActivity">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.provider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/provider_paths" />
</provider>
</application>
</manifest>
\ No newline at end of file
/*
Copyright (c) 2019 CommonsWare, LLC
Licensed under the Apache License, Version 2.0 (the "License"); you may not
use this file except in compliance with the License. You may obtain a copy
of the License at http://www.apache.org/licenses/LICENSE-2.0. Unless required
by applicable law or agreed to in writing, software distributed under the
License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS
OF ANY KIND, either express or implied. See the License for the specific
language governing permissions and limitations under the License.
Covered in detail in the book _Elements of Android Q
https://commonsware.com/AndroidQ
*/
package com.commonsware.android.conferencevideos
import android.content.pm.PackageManager
import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
private const val REQUEST_PERMISSION = 61125
private const val STATE_IN_PERMISSION = "inPermission"
abstract class AbstractPermissionActivity : AppCompatActivity() {
protected abstract val desiredPermissions: Array<String>
protected abstract fun onPermissionDenied()
protected abstract fun onReady(state: Bundle?)
private var isInPermission = false
private var state: Bundle? = null
final override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
this.state = savedInstanceState
if (state != null) {
isInPermission = state!!.getBoolean(STATE_IN_PERMISSION, false)
}
if (desiredPermissions.isEmpty() || hasAllPermissions(desiredPermissions)) {
onReady(state)
} else if (!isInPermission) {
isInPermission = true
ActivityCompat
.requestPermissions(
this,
netPermissions(desiredPermissions),
REQUEST_PERMISSION
)
}
}
override fun onRequestPermissionsResult(
requestCode: Int,
permissions: Array<String>,
grantResults: IntArray
) {
isInPermission = false
if (requestCode == REQUEST_PERMISSION) {
if (hasAllPermissions(desiredPermissions)) {
onReady(state)
} else {
onPermissionDenied()
}
}
}
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
outState.putBoolean(STATE_IN_PERMISSION, isInPermission)
}
private fun hasAllPermissions(perms: Array<String>) =
perms.any { hasPermission(it) }
protected fun hasPermission(perm: String) =
ContextCompat.checkSelfPermission(
this,
perm
) == PackageManager.PERMISSION_GRANTED
private fun netPermissions(wanted: Array<String>) =
wanted.filter { !hasPermission(it) }.toTypedArray()
}
/*
Copyright (c) 2019 CommonsWare, LLC
Licensed under the Apache License, Version 2.0 (the "License"); you may not
use this file except in compliance with the License. You may obtain a copy
of the License at http://www.apache.org/licenses/LICENSE-2.0. Unless required
by applicable law or agreed to in writing, software distributed under the
License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS
OF ANY KIND, either express or implied. See the License for the specific
language governing permissions and limitations under the License.
Covered in detail in the book _Elements of Android Q
https://commonsware.com/AndroidQ
*/
package com.commonsware.android.conferencevideos
import android.app.Application
import org.koin.android.ext.android.startKoin
import org.koin.android.ext.koin.androidContext
import org.koin.androidx.viewmodel.ext.koin.viewModel
import org.koin.dsl.module.module
private val KOIN_MODULE = module {
single { VideoRepository(androidContext()) }
viewModel { MainMotor(get()) }
}
class KoinApp : Application() {
override fun onCreate() {
super.onCreate()
startKoin(this, listOf(KOIN_MODULE))
}
}
\ No newline at end of file
/*
Copyright (c) 2019 CommonsWare, LLC
Licensed under the Apache License, Version 2.0 (the "License"); you may not
use this file except in compliance with the License. You may obtain a copy
of the License at http://www.apache.org/licenses/LICENSE-2.0. Unless required
by applicable law or agreed to in writing, software distributed under the
License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS
OF ANY KIND, either express or implied. See the License for the specific
language governing permissions and limitations under the License.
Covered in detail in the book _Elements of Android Q
https://commonsware.com/AndroidQ
*/
package com.commonsware.android.conferencevideos
import android.Manifest
import android.content.Intent
import android.os.Bundle
import android.view.LayoutInflater
import android.view.ViewGroup
import android.widget.Toast
import androidx.core.os.BuildCompat
import androidx.lifecycle.Observer
import androidx.recyclerview.widget.*
import com.commonsware.android.conferencevideos.databinding.RowBinding
import kotlinx.android.synthetic.main.activity_main.*
import org.koin.androidx.viewmodel.ext.android.viewModel
class MainActivity : AbstractPermissionActivity() {
private val motor: MainMotor by viewModel()
override val desiredPermissions = if (BuildCompat.isAtLeastQ()) arrayOf()
else arrayOf(Manifest.permission.WRITE_EXTERNAL_STORAGE)
override fun onPermissionDenied() {
Toast.makeText(this, getString(R.string.msg_toast), Toast.LENGTH_LONG)
.show()
finish()
}
override fun onReady(state: Bundle?) {
setContentView(R.layout.activity_main)
val manager = LinearLayoutManager(this)
videos.layoutManager = manager
videos.addItemDecoration(DividerItemDecoration(this, manager.orientation))
val adapter = VideoAdapter(layoutInflater) { video ->
if (video.isDownloaded) {
startActivity(
Intent(
Intent.ACTION_VIEW,
video.uri
).addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
)
} else {
motor.download(video)
}
}
videos.adapter = adapter
motor.states.observe(this, Observer { adapter.submitList(it.videos) })
}
}
class VideoAdapter(
private val inflater: LayoutInflater,
private val onClick: (VideoState) -> Unit
) :
ListAdapter<VideoState, RowHolder>(VideoStateDiffer) {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RowHolder {
val binding = RowBinding.inflate(inflater, parent, false)
return RowHolder(binding, onClick)
}
override fun onBindViewHolder(holder: RowHolder, position: Int) {
holder.bind(getItem(position))
}
}
class RowHolder(
private val binding: RowBinding,
private val onClick: (VideoState) -> Unit
) :
RecyclerView.ViewHolder(binding.root) {
fun bind(state: VideoState) {
binding.state = state
binding.root.setOnClickListener { onClick(state) }
binding.executePendingBindings()
}
}
object VideoStateDiffer : DiffUtil.ItemCallback<VideoState>() {
override fun areItemsTheSame(oldItem: VideoState, newItem: VideoState) =
oldItem == newItem
override fun areContentsTheSame(oldItem: VideoState, newItem: VideoState) =
areItemsTheSame(oldItem, newItem)
}
\ No newline at end of file
/*
Copyright (c) 2019 CommonsWare, LLC
Licensed under the Apache License, Version 2.0 (the "License"); you may not
use this file except in compliance with the License. You may obtain a copy
of the License at http://www.apache.org/licenses/LICENSE-2.0. Unless required
by applicable law or agreed to in writing, software distributed under the
License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS
OF ANY KIND, either express or implied. See the License for the specific
language governing permissions and limitations under the License.
Covered in detail in the book _Elements of Android Q
https://commonsware.com/AndroidQ
*/
package com.commonsware.android.conferencevideos
import android.net.Uri
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
private val FILENAMES = listOf(
"AndroidSummit2016-MultiWindow.mp4",
"DevFestFL2018-Room.mp4",
"AndroidSummit2018-Slices.mp4",
"droidconNYC2016-DragDrop.mp4"
)
data class VideoState(
val filename: String,
val uri: Uri?,
val isDownloading: Boolean
) {
val isDownloaded = uri != null
}
data class MainViewState(
val videos: List<VideoState> = listOf()
)
class MainMotor(private val repo: VideoRepository) : ViewModel() {
private val _states = MutableLiveData<MainViewState>().apply {
value = MainViewState()
}
val states: LiveData<MainViewState> = _states
init {
viewModelScope.launch(Dispatchers.Main) {
_states.value = MainViewState(FILENAMES.map { filename ->
VideoState(filename, repo.getLocalUri(filename), false)
})
}
}
fun download(video: VideoState) {
val interim = video.copy(isDownloading = true)
_states.value = MainViewState(
_states.value!!.videos.replace(video, interim)
)
viewModelScope.launch(Dispatchers.Main) {
_states.value = MainViewState(
_states.value!!.videos.replace(
interim,
video.copy(uri = repo.download(video.filename))
)
)
}
}
}
private fun <T> List<T>.replace(original: T, replacement: T): List<T> =
map { item -> if (item == original) replacement else item }
/*
Copyright (c) 2019 CommonsWare, LLC
Licensed under the Apache License, Version 2.0 (the "License"); you may not
use this file except in compliance with the License. You may obtain a copy
of the License at http://www.apache.org/licenses/LICENSE-2.0. Unless required
by applicable law or agreed to in writing, software distributed under the
License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS
OF ANY KIND, either express or implied. See the License for the specific
language governing permissions and limitations under the License.
Covered in detail in the book _Elements of Android Q
https://commonsware.com/AndroidQ
*/
package com.commonsware.android.conferencevideos
import android.content.ContentUris
import android.content.ContentValues
import android.content.Context
import android.media.MediaScannerConnection
import android.net.Uri
import android.os.Environment
import android.provider.MediaStore
import androidx.core.content.FileProvider
import androidx.core.os.BuildCompat
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import okhttp3.Request
import okio.Okio
import java.io.File
private const val URL_BASE = "https://commonsware.com/presos/"
private val PROJECTION = arrayOf(MediaStore.Video.Media._ID)
private const val QUERY = MediaStore.Video.Media.DISPLAY_NAME + " = ?"
private const val AUTHORITY = "${BuildConfig.APPLICATION_ID}.provider"
class VideoRepository(private val context: Context) {
private val ok = OkHttpClient()
private val collection =
if (BuildCompat.isAtLeastQ()) MediaStore.Video.Media.getContentUri(
MediaStore.VOLUME_EXTERNAL
) else MediaStore.Video.Media.EXTERNAL_CONTENT_URI
suspend fun getLocalUri(filename: String): Uri? =
withContext(Dispatchers.IO) {
val resolver = context.contentResolver
resolver.query(collection, PROJECTION, QUERY, arrayOf(filename), null)
?.use { cursor ->
if (cursor.count > 0) {
cursor.moveToFirst()
return@withContext ContentUris.withAppendedId(
collection,
cursor.getLong(0)
)
}
}
null
}
suspend fun download(filename: String): Uri =
if (BuildCompat.isAtLeastQ()) downloadQ(filename)
else downloadLegacy(filename)
private suspend fun downloadQ(filename: String): Uri =
withContext(Dispatchers.IO) {
val url = URL_BASE + filename
val response = ok.newCall(Request.Builder().url(url).build()).execute()
if (response.isSuccessful) {
val values = ContentValues().apply {
put(MediaStore.Video.Media.DISPLAY_NAME, filename)
put(MediaStore.Video.Media.MIME_TYPE, "video/mp4")
put(MediaStore.Video.Media.IS_PENDING, 1)
}
val resolver = context.contentResolver
val uri = resolver.insert(collection, values)
uri?.let {
resolver.openOutputStream(uri)?.use { outputStream ->
val sink = Okio.buffer(Okio.sink(outputStream))
response.body()?.source()?.let { sink.writeAll(it) }
sink.close()
}
values.clear()
values.put(MediaStore.Video.Media.IS_PENDING, 0)
resolver.update(uri, values, null, null)
} ?: throw RuntimeException("MediaStore failed for some reason")
uri
} else {
throw RuntimeException("OkHttp failed for some reason")
}
}
private suspend fun downloadLegacy(filename: String): Uri =
withContext(Dispatchers.IO) {
val file = File(
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_MOVIES),
filename
)
val url = URL_BASE + filename
val response = ok.newCall(Request.Builder().url(url).build()).execute()
if (response.isSuccessful) {
val sink = Okio.buffer(Okio.sink(file))
response.body()?.source()?.let { sink.writeAll(it) }
sink.close()
MediaScannerConnection.scanFile(
context,
arrayOf(file.absolutePath),
arrayOf("video/mp4"),
null
)
FileProvider.getUriForFile(context, AUTHORITY, file)
} else {
throw RuntimeException("OkHttp failed for some reason")
}
}
}
\ No newline at end of file
<vector xmlns:aapt="http://schemas.android.com/aapt"
xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportHeight="108"
android:viewportWidth="108">
<path
android:fillType="evenOdd"
android:pathData="M32,64C32,64 38.39,52.99 44.13,50.95C51.37,48.37 70.14,49.57 70.14,49.57L108.26,87.69L108,109.01L75.97,107.97L32,64Z"
android:strokeColor="#00000000"
android:strokeWidth="1">
<aapt:attr name="android:fillColor">
<gradient
android:endX="78.5885"
android:endY="90.9159"
android:startX="48.7653"
android:startY="61.0927"
android:type="linear">
<item
android:color="#44000000"
android:offset="0.0" />
<item
android:color="#00000000"
android:offset="1.0" />
</gradient>
</aapt:attr>
</path>
<path
android:fillColor="#FFFFFF"
android:fillType="nonZero"
android:pathData="M66.94,46.02L66.94,46.02C72.44,50.07 76,56.61 76,64L32,64C32,56.61 35.56,50.11 40.98,46.06L36.18,41.19C35.45,40.45 35.45,39.3 36.18,38.56C36.91,37.81 38.05,37.81 38.78,38.56L44.25,44.05C47.18,42.57 50.48,41.71 54,41.71C57.48,41.71 60.78,42.57 63.68,44.05L69.11,38.56C69.84,37.81 70.98,37.81 71.71,38.56C72.44,39.3 72.44,40.45 71.71,41.19L66.94,46.02ZM62.94,56.92C64.08,56.92 65,56.01 65,54.88C65,53.76 64.08,52.85 62.94,52.85C61.8,52.85 60.88,53.76 60.88,54.88C60.88,56.01 61.8,56.92 62.94,56.92ZM45.06,56.92C46.2,56.92 47.13,56.01 47.13,54.88C47.13,53.76 46.2,52.85 45.06,52.85C43.92,52.85 43,53.76 43,54.88C43,56.01 43.92,56.92 45.06,56.92Z"
android:strokeColor="#00000000"
android:strokeWidth="1" />
</vector>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24.0"
android:viewportHeight="24.0">
<path
android:fillColor="#FF000000"
android:pathData="M19.35,10.04C18.67,6.59 15.64,4 12,4 9.11,4 6.6,5.64 5.35,8.04 2.34,8.36 0,10.91 0,14c0,3.31 2.69,6 6,6h13c2.76,0 5,-2.24 5,-5 0,-2.64 -2.05,-4.78 -4.65,-4.96zM17,13l-5,5 -5,-5h3V9h4v4h3z"/>
</vector>
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportHeight="108"
android:viewportWidth="108">
<path
android:fillColor="#008577"
android:pathData="M0,0h108v108h-108z" />
<path
android:fillColor="#00000000"
android:pathData="M9,0L9,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M19,0L19,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M29,0L29,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M39,0L39,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"