2022 Jul. 18.
2022 Feb. 13.
2021 Dec. 05.
2021 Nov. 14.
2021 Sep. 16.
ソース保管場所
https://bitbucket.org/arsmus/randomsmbsoundplay/src/master/
概要
SMBサーバーからランダムに1つの音声ファイルをストリーミング再生する。
SMBサーバーにつながらなかったら、android端末の外部共有ストレージからランダムに1つの音声ファイルをストリーミング再生する。
再生にはサードパーティ再生アプリをIntent起動する。
ViewModelを使ったワーカースレッド内でSMBサーバーに接続する。
SMBサーバーへの接続にはjcifs-ngを利用。
ViewModelのLiveData機能でSMBサーバーファイルのファイルパスをメインスレッドに渡す。
メインスレッド内で、SMBサーバーファイルのファイルパスをURIにパースして音声再生Intentに渡し、Intentを発出する。
ContentResolverで得たURIをそのままIntentに与えても動作しない。パスを取得し、そのパスをURIにパースする必要がある。パスは _dataカラムから取得できる。
インストールしたandroid端末
android バージョン 7
android バージョン 11
コンパイル環境
compileSdkVersion 31
minSdkVersion 24
targetSdkVersion 31
android studioのプロジェクトのapp/libsにjcifs-ng, bcprovライブラリを配置
jcifs-ng-2.1.6.jar
https://mvnrepository.com/artifact/eu.agno3.jcifs/jcifs-ng/2.1.6 のFilesのbundleをクリックしてダウンロード
bcprov-jdk15to18-1.69.jar
https://mvnrepository.com/artifact/org.bouncycastle/bcprov-jdk15to18/1.69 のFilesのjarをクリックしてダウンロード
build.gradle(Module:app)
次の依存関係設定を記述する
implementation "androidx.lifecycle:lifecycle-livedata-ktx:2.4.1" implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:2.4.1" implementation files('libs/jcifs-ng-2.1.7.jar') implementation group: 'org.bouncycastle', name: 'bcprov-jdk15to18', version: '1.70' implementation group: 'org.slf4j', name: 'slf4j-api', version: '1.7.32' implementation group: 'org.slf4j', name: 'slf4j-simple', version: '1.7.32'
全体
plugins { id 'com.android.application' id 'kotlin-android' } android { compileSdk 31 defaultConfig { applicationId "YOURPROJECT" minSdk 24 targetSdk 31 versionCode 1 versionName "1.0" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" } buildTypes { release { minifyEnabled false proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' } } compileOptions { sourceCompatibility JavaVersion.VERSION_1_8 targetCompatibility JavaVersion.VERSION_1_8 } kotlinOptions { jvmTarget = '1.8' } buildFeatures { viewBinding true } } dependencies { implementation 'androidx.core:core-ktx:1.7.0' implementation 'androidx.appcompat:appcompat:1.4.1' implementation 'com.google.android.material:material:1.5.0' implementation 'androidx.constraintlayout:constraintlayout:2.1.3' implementation 'androidx.legacy:legacy-support-v4:1.0.0' implementation 'androidx.recyclerview:recyclerview:1.2.1' testImplementation 'junit:junit:4.13.2' androidTestImplementation 'androidx.test.ext:junit:1.1.3' androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0' implementation "androidx.lifecycle:lifecycle-livedata-ktx:2.4.1" implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:2.4.1" implementation files('libs/jcifs-ng-2.1.7.jar') implementation group: 'org.bouncycastle', name: 'bcprov-jdk15to18', version: '1.70' implementation group: 'org.slf4j', name: 'slf4j-api', version: '1.7.32' implementation group: 'org.slf4j', name: 'slf4j-simple', version: '1.7.32' implementation "androidx.preference:preference-ktx:1.2.0" }
AndroidManifest.xml
<?xml version="1.0" encoding="utf-8"?> <manifest xmlns:android="http://schemas.android.com/apk/res/android" package="YOURPROJECT"> <uses-permission android:name="android.permission.INTERNET"/> <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/> <uses-permission android:name="android.permission.ACCESS_WIFI_STATE"/> <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" /> <application android:allowBackup="true" android:icon="@mipmap/ic_launcher" android:label="@string/app_name" android:roundIcon="@mipmap/ic_launcher_round" android:supportsRtl="true" android:theme="@style/Theme.RandomSmbSoundPlay"> <activity android:name=".MainActivity" android:exported="true"> <intent-filter> <action android:name="android.intent.action.MAIN" /> <category android:name="android.intent.category.LAUNCHER" /> </intent-filter> </activity> </application> </manifest>
activity_main.xml
<?xml version="1.0" encoding="utf-8"?> <androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent" android:layout_height="match_parent" tools:context=".MainActivity"> <TextView android:id="@+id/tvInfo01" android:layout_width="match_parent" android:layout_height="wrap_content" app:layout_constraintLeft_toLeftOf="parent" app:layout_constraintRight_toRightOf="parent" app:layout_constraintTop_toTopOf="parent" app:layout_constraintBottom_toTopOf="@+id/tvInfo02" /> <TextView android:id="@+id/tvInfo02" android:layout_width="match_parent" android:layout_height="wrap_content" app:layout_constraintLeft_toLeftOf="parent" app:layout_constraintRight_toRightOf="parent" app:layout_constraintTop_toBottomOf="@+id/tvInfo01" app:layout_constraintBottom_toTopOf="@+id/tvFilePath" /> <TextView android:id="@+id/tvFilePath" android:layout_width="match_parent" android:layout_height="wrap_content" app:layout_constraintLeft_toLeftOf="parent" app:layout_constraintRight_toRightOf="parent" app:layout_constraintTop_toBottomOf="@+id/tvInfo02" app:layout_constraintBottom_toTopOf="@+id/tvFileSize" /> <TextView android:id="@+id/tvFileSize" android:layout_width="wrap_content" android:layout_height="wrap_content" app:layout_constraintLeft_toLeftOf="parent" app:layout_constraintRight_toLeftOf="@id/btnQuit" app:layout_constraintTop_toBottomOf="@+id/tvFilePath" app:layout_constraintBottom_toTopOf="@id/btnPlay" /> <Button android:id="@+id/btnQuit" android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="@string/quit" app:layout_constraintLeft_toLeftOf="@id/btnReset" app:layout_constraintRight_toRightOf="@id/btnReset" app:layout_constraintTop_toBottomOf="@id/tvFilePath" app:layout_constraintBottom_toTopOf="@id/btnReset" app:layout_constraintBaseline_toBaselineOf="@id/tvFileSize" /> <Button android:id="@+id/btnPlay" android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="@string/play" app:layout_constraintStart_toStartOf="parent" app:layout_constraintEnd_toStartOf="@+id/btnReplay" app:layout_constraintTop_toBottomOf="@+id/tvFileSize" app:layout_constraintBottom_toBottomOf="parent" /> <Button android:id="@+id/btnReplay" android:text="@string/replay" android:layout_width="wrap_content" android:layout_height="wrap_content" app:layout_constraintStart_toEndOf="@+id/btnPlay" app:layout_constraintEnd_toStartOf="@+id/btnReset" app:layout_constraintTop_toBottomOf="@+id/tvFileSize" app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintBaseline_toBaselineOf="@+id/btnPlay" /> <Button android:id="@+id/btnReset" android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="@string/reset" app:layout_constraintStart_toEndOf="@+id/btnReplay" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintTop_toBottomOf="@+id/tvFileSize" app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintBaseline_toBaselineOf="@id/btnReplay" /> </androidx.constraintlayout.widget.ConstraintLayout>
dialog_smb_server_setting.xml
<?xml version="1.0" encoding="utf-8"?> <androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" android:layout_width="match_parent" android:layout_height="match_parent"> <EditText android:id="@+id/etDomain" android:layout_width="match_parent" android:layout_height="wrap_content" android:hint="@string/hintDomain" android:autofillHints="@string/hintDomain" android:inputType="text" app:layout_constraintBottom_toTopOf="@id/etSmbPath" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="parent" /> <EditText android:id="@+id/etSmbPath" android:layout_width="match_parent" android:layout_height="wrap_content" android:hint="@string/hintSmbRootPath" android:autofillHints="@string/hintSmbRootPath" android:inputType="textUri" app:layout_constraintBottom_toTopOf="@+id/etSmbUser" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toBottomOf="@+id/etDomain" /> <EditText android:id="@+id/etSmbUser" android:layout_width="match_parent" android:layout_height="wrap_content" android:autofillHints="@string/hintUserName" android:hint="@string/hintUserName" android:inputType="textUri" app:layout_constraintBottom_toTopOf="@+id/etSmbPassword" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toBottomOf="@+id/etSmbPath" /> <EditText android:id="@+id/etSmbPassword" android:layout_width="match_parent" android:layout_height="wrap_content" android:autofillHints="@string/hintPassword" android:hint="@string/hintPassword" android:inputType="textPassword" app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toBottomOf="@+id/etSmbUser" /> </androidx.constraintlayout.widget.ConstraintLayout>
played_files_list.xml
<?xml version="1.0" encoding="utf-8"?> <androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android" android:layout_width="match_parent" android:layout_height="match_parent"> <ListView android:id="@+id/listViewFiles" android:layout_width="match_parent" android:layout_height="match_parent" /> </androidx.constraintlayout.widget.ConstraintLayout>
optionsmenu.xml
<?xml version="1.0" encoding="utf-8"?> <menu xmlns:android="http://schemas.android.com/apk/res/android"> <item android:id="@+id/itemSmbServer" android:title="@string/menuItemServer" /> <item android:id="@+id/itemPlayedFileHistory" android:title="@string/menuItemPlayedHistory" /> </menu>
strings.xml
<resources> <string name="app_name">RandomSmbSoundPlay</string> <string name="kb">%sKB</string> <string name="sentIntent">Sent Intent for playing audio</string> <string name="waitMinutes">Wait minutes to connect SMB server</string> <string name="hintPath">Filepath</string> <string name="hintSize">Filesize</string> <string name="play">Play</string> <string name="replay">Replay</string> <string name="reset">Reset</string> <string name="quit">Quit</string> <string name="tryPlayAndroidFile">Trying to play a file in local device</string> <string name="notConnectedSmb">Not connected to SMB server</string> <string name="fromNowTryConnectSmb">Connect to SMB server from now</string> <string name="tryConnectingSmb">Trying to connect to SMB server</string> <string name="connectedSmb">Connected to SMB server</string> <string name="failedConnectSmb">Failed to connect to SMB server</string> <string name="notGetAudioFile">Not got an audio file</string> <string name="fromNowTryGetAudioFile">Get an audio file from now</string> <string name="tryGetAudioFile">Trying to get an audio file</string> <string name="gotAudioFile">Got an audio file</string> <string name="failedGetAudioFile">Failed to get an audio file</string> <string name="notGetSmbAudioFile">Not got a smb audio file</string> <string name="fromNowTryGetSmbAudioFile">Get a smb audio file from now</string> <string name="tryGetSmbAudioFile">Trying to get a smb audio file</string> <string name="gotSmbAudioFile">Got a smb audio file</string> <string name="failedGetSmbAudioFile">Failed to get a smb audio file</string> <string name="gotLocalAudioFile">Got an audio file from local device</string> <string name="server">Server</string> <string name="domain">Domain</string> <string name="smbPath">SMB_Path</string> <string name="userName">UserName</string> <string name="password">Password</string> <string name="hintDomain">192.168.1.1</string> <string name="hintSmbRootPath">/SMB/PATH/</string> <string name="hintUserName">user</string> <string name="hintPassword">password</string> <string name="noNetwork">No network</string> <string name="menuItemServer">Server Setting</string> <string name="menuItemPlayedHistory">Play History</string> <string name="pathHistoryFile">path_history.txt</string> <string name="noHistory">No history</string> <string name="noPermission">No permission is granted.</string> </resources>
MainActivity.kt
// 2022 Feb. 13. // 2021 Dec. 28. // 2021 Nov. 14. // 2021 Oct. 31. // 2021 Sep. 06. // Ryuichi Hashimoto import android.Manifest import android.content.ContentUris import android.content.Context import android.content.Intent import android.content.pm.PackageManager import android.net.ConnectivityManager import android.net.Uri import android.os.Build import android.os.Bundle import android.provider.MediaStore import android.view.Menu import android.view.MenuItem import android.widget.Toast import androidx.appcompat.app.AppCompatActivity import androidx.fragment.app.DialogFragment import androidx.lifecycle.Observer import androidx.lifecycle.ViewModelProvider import androidx.preference.PreferenceManager import net.sytes.rokkosan.randomsmbsoundplay.databinding.ActivityMainBinding import java.io.* import java.util.* import java.util.concurrent.TimeUnit class MainActivity : AppCompatActivity(),SmbServerSettingDialogFragment.DialogListener { ////////////////////////////////////////////// // Please replace these values to your data // /* private var smbDomain: String = "192.168.1.1" private var smbRoot: String = "/SMB/SERVER/DIR/" private var smbUser: String = "" private var smbPassword: String = "" */ private var smbDomain: String = "192.168.1.1" private var smbRoot: String = "/" private var smbUser: String = "" private var smbPassword: String = "" ////////////////////////////////////////////// private var smbFilepath: String = "" private lateinit var binding: ActivityMainBinding private lateinit var audioUri: Uri val defaultSharedPreferences by lazy {PreferenceManager.getDefaultSharedPreferences(getApplicationContext())} lateinit var smbViewModel: SmbViewModel lateinit var smbViewModelFactory: SmbViewModelFactory lateinit var smbUrl: String private val PERMISSION_READ_EX_STOR: Int = 100 lateinit var appContext: Context // option menu override fun onCreateOptionsMenu(menu: Menu): Boolean { super.onCreateOptionsMenu(menu) val inflater = menuInflater inflater.inflate(R.menu.optionsmenu, menu) return true } //Option menuのitemがクリックされた時の動作 override fun onOptionsItemSelected(item: MenuItem): Boolean { when(item.itemId) { R.id.itemSmbServer -> { // ダイアログに渡す値をBundleにセット val args = Bundle() args.putString("smbDomain", smbDomain ) args.putString("smbRootPath", smbRoot ) args.putString("smbUser", smbUser ) args.putString("smbPassword", smbPassword ) val smbSettingdialog = SmbServerSettingDialogFragment() smbSettingdialog.setArguments(args) // ダイアログを表示 smbSettingdialog.show(supportFragmentManager, "simple") } R.id.itemPlayedFileHistory -> { val playedFilesDialog = ListPlayedFilesDialogFragment() playedFilesDialog.show(supportFragmentManager, "simple") } } return super.onOptionsItemSelected(item) } // SmbServerSettingDialogFragment.DialogListenerインターフェースのメソッドの実装 override fun onDialogMapReceive(dialog: DialogFragment, smbSettings: MutableMap<String, String>) { // smbSettingdialogからの値を受け取る with ( defaultSharedPreferences.edit()) { putString("smbDomain", smbSettings["smbDomain"]) putString("smbPath", smbSettings["smbPath"]) putString("smbUser", smbSettings["smbUser"]) putString("smbPassword", smbSettings["smbPassword"]) apply() } smbDomain = smbSettings["smbDomain"] ?: "" smbRoot = smbSettings["smbPath"] ?: "" smbUser = smbSettings["smbUser"] ?: "" smbPassword = smbSettings["smbPassword"] ?: "" } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) // setContentView(R.layout.activity_main) binding = ActivityMainBinding.inflate(layoutInflater) setContentView(binding.root) appContext = applicationContext // get smb setting values with (defaultSharedPreferences) { smbDomain = getString("smbDomain", "") ?: "" smbRoot = getString("smbPath", "") ?: "" smbUser = getString("smbUser", "") ?: "" smbPassword = getString("smbPassword", "") ?: "" } // if any of values is empty, show dialog to get value if ( smbDomain.length < 1 || smbRoot.length < 1 || smbUser.length < 1 || smbPassword.length < 1 ) { // smb設定ダイアログに渡す値をBundleにセット val args = Bundle() with(args) { putString("smbDomain", smbDomain ) putString("smbRootPath", smbRoot ) putString("smbUser", smbUser ) putString("smbPassword", smbPassword ) } val smbSettingdialog = SmbServerSettingDialogFragment() smbSettingdialog.setArguments(args) // smb設定ダイアログを表示 smbSettingdialog.show(supportFragmentManager, "simple") } // リプレイボタン・リセットボタン if (binding.btnPlay.isEnabled) { binding.btnReplay.isEnabled = false binding.btnReset.isEnabled = true binding.btnQuit.isEnabled = true } else { binding.btnReplay.isEnabled = true binding.btnReset.isEnabled = true binding.btnQuit.isEnabled = true } // play-button to connect to SMB server and play audio binding.btnPlay.setOnClickListener { // Check network status. // Grateful for https://qiita.com/taowata/items/4609dcddc3ddb4840fd6 // Network確認のため、ConnectivityManagerを取得する val cm = getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager // NetworkCapabilitiesの取得 // 引数にcm.activeNetworkを指定し、現在アクティブなデフォルトネットワークに対応するNetworkオブジェクトを渡している val capabilities = cm.getNetworkCapabilities(cm.activeNetwork) if (capabilities == null) { binding.tvInfo01.setText(R.string.noNetwork) playAndroidExternalAudio() } else { binding.tvInfo01.setText(R.string.waitMinutes) binding.btnPlay.isEnabled = false binding.btnReplay.isEnabled = true binding.btnReset.isEnabled = true binding.btnQuit.isEnabled = true // set smbUrl to prepare for connecting to SMB server smbUrl = "smb://" + smbDomain + smbRoot if (smbUrl.takeLast(1) != "/") { smbUrl += "/" // smbUrl文字列の末尾はスラッシュ(/)とする } /* * get ViewModel for SmbConnection */ smbViewModelFactory = SmbViewModelFactory(smbUser, smbPassword, smbDomain, smbUrl) smbViewModel = ViewModelProvider(this, smbViewModelFactory).get(SmbViewModel::class.java) /* * create Observers */ // ファイルパス取得 val pathObserver = Observer<String> { sPath -> smbFilepath = sPath binding.tvFilePath.text = sPath binding.btnReplay.isEnabled = true binding.btnReset.isEnabled = true } // ファイルサイズ取得 val sizeObserver = Observer<Long> { sSize -> binding.tvFileSize.setText(getString(R.string.kb, (sSize / 1000L).toString())) } // SMB接続状況に応じた処理 val connectStatusObserver = Observer<SmbViewModel.ProcessStatus> { status -> when (status!!) { SmbViewModel.ProcessStatus.NotYetGot -> binding.tvInfo01.setText(R.string.notConnectedSmb) SmbViewModel.ProcessStatus.FromNow -> binding.tvInfo01.setText(R.string.fromNowTryConnectSmb) SmbViewModel.ProcessStatus.Trying -> binding.tvInfo01.setText(R.string.tryConnectingSmb) SmbViewModel.ProcessStatus.Got -> binding.tvInfo01.setText(R.string.connectedSmb) SmbViewModel.ProcessStatus.Failed -> { binding.tvInfo01.setText(R.string.failedConnectSmb) binding.tvInfo02.setText(R.string.tryGetAudioFile) // play an audio file in external storage in android device playAndroidExternalAudio() binding.btnReplay.isEnabled = true binding.btnReset.isEnabled = true } } } // SMBサーバーの音声ファイル取得状況に応じた処理 val getSmbAudioFileStatusObserver = Observer<SmbViewModel.ProcessStatus> { status -> when (status!!) { SmbViewModel.ProcessStatus.NotYetGot -> binding.tvInfo02.setText(R.string.notGetSmbAudioFile) SmbViewModel.ProcessStatus.FromNow -> binding.tvInfo02.setText(R.string.fromNowTryGetSmbAudioFile) SmbViewModel.ProcessStatus.Trying -> binding.tvInfo02.setText(R.string.tryGetSmbAudioFile) SmbViewModel.ProcessStatus.Got -> { binding.tvInfo02.setText(R.string.gotSmbAudioFile) // create and send Intent of audio-player // play with 3rd party media play application try { val audioIntent = Intent() audioIntent.action = Intent.ACTION_VIEW audioIntent.setDataAndType(Uri.parse(smbFilepath), "audio/*") // if you decide VLC as the player, uncomment the next line // audioIntent.setPackage("org.videolan.vlc") startActivity(audioIntent) binding.tvInfo01.setText(R.string.sentIntent) // save audio-file-path to storage lateinit var fw: FileWriter val savePathFile = File(appContext.filesDir, getString(R.string.pathHistoryFile)) if (savePathFile.exists()) { fw = FileWriter(savePathFile, true) } else { fw = FileWriter(savePathFile) } try { fw.write("$smbFilepath,"); } catch(e: IOException) { binding.tvInfo01.setText(e.toString()) } finally { try { fw.close() } catch(e: IOException) { binding.tvInfo01.setText(e.toString()) } } } catch (e: Exception) { binding.tvInfo01.setText(e.toString()) } } SmbViewModel.ProcessStatus.Failed -> { // SMBサーバーの音声ファイル取得に失敗 binding.tvInfo02.setText(R.string.failedGetSmbAudioFile) // play an audio file in external storage in android device binding.tvInfo01.setText(R.string.failedConnectSmb) binding.tvInfo02.setText(R.string.tryPlayAndroidFile) playAndroidExternalAudio() } } } // ViewModelでSMBサーバーに接続し音声ファイルを取得する smbViewModel.connectSmbSelectAudioFile() // Proceed according to status of SMB-connection in the screen smbViewModel.smbConnectionStatus.observe(this, connectStatusObserver) // Display filepath, filesize in the screen smbViewModel.smbPath.observe(this, pathObserver) smbViewModel.smbSize.observe(this, sizeObserver) // proceed according to status of getting SMB-audio-file smbViewModel.getAudioFileStatus.observe(this, getSmbAudioFileStatusObserver) } } // replay-button binding.btnReplay.setOnClickListener { binding.btnPlay.isEnabled = false binding.btnReplay.isEnabled = true binding.btnReset.isEnabled = true binding.btnQuit.isEnabled = true if ( "smb" == binding.tvFilePath.text.toString().substring(0, 3)) { // in case SMB // create and send Intent of audio-player // play with 3rd party media play application try { val audioIntent = Intent() audioIntent.action = Intent.ACTION_VIEW audioIntent.setDataAndType(Uri.parse(smbFilepath), "audio/*") // if you decide VLC as the player, uncomment the next line // audioIntent.setPackage("org.videolan.vlc") startActivity(audioIntent) binding.tvInfo01.setText(R.string.sentIntent) } catch (e: Exception) { binding.tvInfo01.setText(e.toString()) } } else { // in case to play audio in the storage in android device // パーミッションを取得しているのでオーディオファイルをIntentに乗せて発出する try { val audioIntent = Intent() audioIntent.action = Intent.ACTION_VIEW audioIntent.setDataAndType(audioUri, "audio/*") // if you decide VLC as the player, uncomment the next line // audioIntent.setPackage("org.videolan.vlc") startActivity(audioIntent) binding.tvInfo02.setText(R.string.sentIntent) } catch (e: Exception) { binding.tvInfo02.setText(e.toString()) } } } // reset-button binding.btnReset.setOnClickListener { binding.btnPlay.isEnabled = true binding.btnReplay.isEnabled = false binding.btnReset.isEnabled = true binding.btnQuit.isEnabled = true // restart application try { smbViewModel.smb.close() } catch (e: Exception) { } val launchIntent = baseContext.packageManager .getLaunchIntentForPackage(baseContext.packageName) launchIntent!!.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) finish() startActivity(launchIntent) } // quit-button binding.btnQuit.setOnClickListener { try { smbViewModel.smb.close() } catch (e: Exception) { } finishAndRemoveTask() } } // Activityのdestroy時のインスタンス保存 // onPauseの直後に呼ばれる override fun onSaveInstanceState(outState: Bundle) { super.onSaveInstanceState(outState) val info01Text: String = binding.tvInfo01.text.toString() outState.putString("INFO01", info01Text) val info02Text: String = binding.tvInfo02.text.toString() outState.putString("INFO02", info02Text) val pathText: String = binding.tvFilePath.text.toString() outState.putString("PATH", pathText ) val sizeText: String = binding.tvFileSize.text.toString() outState.putString("SIZE", sizeText ) outState.putBoolean("btnPlay", binding.btnPlay.isEnabled) outState.putBoolean("btnReplay", binding.btnReplay.isEnabled) outState.putBoolean("btnReset", binding.btnReset.isEnabled) outState.putBoolean("btnQuit", binding.btnQuit.isEnabled) } // 保存されたインスタンスの読み込み override fun onRestoreInstanceState(savedInstanceState: Bundle) { super.onRestoreInstanceState(savedInstanceState) val info01Text: String = savedInstanceState.getString("INFO01") ?: "" binding.tvInfo01.setText(info01Text) val info02Text: String = savedInstanceState.getString("INFO02") ?: "" binding.tvInfo02.setText(info02Text) val pathText: String = savedInstanceState.getString("PATH") ?: "" binding.tvFilePath.setText(pathText) smbFilepath = pathText val sizeText: String = savedInstanceState.getString("SIZE") ?: "" binding.tvFileSize.setText(sizeText) binding.btnPlay.isEnabled = savedInstanceState.getBoolean("btnPlay") binding.btnReplay.isEnabled = savedInstanceState.getBoolean("btnReplay") binding.btnReset.isEnabled = savedInstanceState.getBoolean("btnReset") binding.btnQuit.isEnabled = savedInstanceState.getBoolean("btnQuit") } /* * create and send Intent of audio-player * play with 3rd party media play application */ private fun playAndroidExternalAudio() { // パーミッションがあれば処理を進める。無ければ取得する if ( isGrantedReadStorage()) { getPlayStorageMedia() } else { requestPermissions( arrayOf(Manifest.permission.READ_EXTERNAL_STORAGE), PERMISSION_READ_EX_STOR ) // requestPermissions()の結果に応じた処理がonRequestPermissionsResult()で行われる } } // アプリにパーミッションが付与されているかどうかを確認するメソッド fun isGrantedReadStorage(): Boolean { return (checkSelfPermission(Manifest.permission.READ_EXTERNAL_STORAGE) == PackageManager.PERMISSION_GRANTED) } // requestPermissions()の結果に対する処理を行うメソッド override fun onRequestPermissionsResult( requestCode: Int, permissions: Array<String>, grantResults: IntArray) { when (requestCode) { PERMISSION_READ_EX_STOR -> { if (grantResults.isEmpty()) { // STOP WORK throw RuntimeException("Empty permission result") } if (grantResults[0] == PackageManager.PERMISSION_GRANTED) { // permission is got. // YOU CAN CONTINUE YOUR WORK WITH EXTERNAL_STORAGE HERE getPlayStorageMedia() } else { // no permission is got if (shouldShowRequestPermissionRationale( Manifest.permission.READ_EXTERNAL_STORAGE)) { // permission request was denied. // User declined, but system can still ask for more // request permission agein requestPermissions( arrayOf(Manifest.permission.READ_EXTERNAL_STORAGE), PERMISSION_READ_EX_STOR) } else { // permission request was denied and requested "no not ask". // User declined and system can't ask. // CODES in case of rejection to get permission here Toast.makeText(applicationContext, R.string.noPermission, Toast.LENGTH_LONG).show() } } } } } fun getPlayStorageMedia() { var pathOfUri: String= "" val collection = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { MediaStore.Audio.Media.getContentUri( MediaStore.VOLUME_EXTERNAL ) } else { MediaStore.Audio.Media.EXTERNAL_CONTENT_URI } val columns = arrayOf( MediaStore.Audio.Media._ID, MediaStore.Audio.Media.DISPLAY_NAME, MediaStore.Audio.Media.SIZE, "_data" ) // 1分間以上のAudioを指定する val selection = "${MediaStore.Audio.Media.DURATION} >= ?" val selectionArgs = arrayOf( TimeUnit.MILLISECONDS.convert(1, TimeUnit.MINUTES).toString() ) val resolver = applicationContext.contentResolver val query = resolver.query( collection, //データの種類 columns, //取得する項目 nullは全部 selection, //フィルター条件 nullはフィルタリング無し selectionArgs, //フィルター用のパラメータ null //並べ替え ) // Log.d( "MyApp" , Arrays.toString( query?.getColumnNames() ) ) //項目名の一覧を出力 val numCount = query?.count // Log.d("MyApp", "Num raws : $numCount") query?.use { cursor -> val idColumn = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media._ID) val displayNameColumn = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.DISPLAY_NAME) val sizeColumn = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.SIZE) val column_index = cursor.getColumnIndexOrThrow("_data") cursor.moveToPosition(getRandomNum(numCount!!)) val id = cursor.getLong(idColumn) val displayName = cursor.getString(displayNameColumn) val mediaSize = cursor.getInt(sizeColumn) val contentUri: Uri = ContentUris.withAppendedId( MediaStore.Video.Media.EXTERNAL_CONTENT_URI,id) pathOfUri = cursor.getString(column_index) binding.tvInfo02.setText(R.string.gotLocalAudioFile) binding.tvFilePath.setText(pathOfUri) val sizeKb = (mediaSize / 1000 ).toString() + "KB" binding.tvFileSize.setText(sizeKb) // play audio val audioIntent = Intent() audioIntent.action = Intent.ACTION_VIEW audioIntent.setDataAndType(Uri.parse(pathOfUri), "audio/*") // audioIntent.setDataAndType(contentUri, "audio/*") <- NG audioIntent.setPackage("org.videolan.vlc") startActivity(audioIntent) binding.tvInfo02.setText(R.string.sentIntent) // save audio-file-path to storage lateinit var fw: FileWriter val savePathFile = File(appContext.filesDir, getString(R.string.pathHistoryFile)) fw = if (savePathFile.exists()) { FileWriter(savePathFile, true) } else { FileWriter(savePathFile) } try { fw.write("$pathOfUri,"); } catch(e: IOException) { binding.tvInfo01.setText(e.toString()) } finally { try { fw.close() } catch(e: IOException) { binding.tvInfo01.setText(e.toString()) } } /* Log.d( "MyApp", "id: $id, name: $displayName, ${mediaSize}Byte, uri: $contentUri" ) */ // Log.d("MyApp", "Selected " + pathOfUri!!) } query?.close() return } // 0以上、maxNum未満の範囲でランダムな数を1つ返す fun getRandomNum(maxNum: Int): Int { val random = Random() return random.nextInt(maxNum) } }
SmbViewModel.kt
// 2021 Dec. 05. // 2021 Oct. 30. // 2021 Sep. 06. // Ryuichi Hashimoto import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import jcifs.config.PropertyConfiguration import jcifs.context.BaseContext import jcifs.smb.NtlmPasswordAuthenticator import jcifs.smb.SmbFile import kotlinx.coroutines.* import java.util.* class SmbViewModel(private val user: String, private val password: String, private val domain: String, private val smbUrl: String): ViewModel() { private val audioExtension: String = "\\.mp3|\\.wav|\\.aac|\\.au|\\.gsm|\\.m4a|\\.ogg|\\.flac" lateinit var smb: SmbFile data class AudioFileProperty(val smbPath: String, val fileSize: Long) enum class ProcessStatus { NotYetGot, FromNow, Trying, Got, Failed } // LiveDataを設定する private var _smbConnectionStatus = MutableLiveData(ProcessStatus.NotYetGot) val smbConnectionStatus: LiveData<ProcessStatus> get() = _smbConnectionStatus private var _getAudioFileStatus = MutableLiveData(ProcessStatus.NotYetGot) val getAudioFileStatus: LiveData<ProcessStatus> get() = _getAudioFileStatus private val _smbPath: MutableLiveData<String> by lazy { // MutableLiveData<T>は、valでmutableな変数を宣言できる MutableLiveData<String>() } val smbPath: LiveData<String> // 下記により、smbPathの読み込み時は _smbPathが読み出される get() = _smbPath val _smbSize: MutableLiveData<Long> by lazy { MutableLiveData<Long>() } val smbSize: LiveData<Long> get() = _smbSize // Main process in this ViewModel fun connectSmbSelectAudioFile() { viewModelScope.launch(Dispatchers.IO) { // connect to SMB server in worker thread try { _smbConnectionStatus.postValue(ProcessStatus.Trying) // tyring to connect to SMB connectSmb() } catch (e: Exception) { _smbConnectionStatus.postValue(ProcessStatus.Failed) // Failed to connect to SMB" return@launch } // got connection to SMB _smbConnectionStatus.postValue(ProcessStatus.Got) // connected to SMB // select an audio file in SMB _getAudioFileStatus.postValue(ProcessStatus.Trying) // trying to get an audio file val audioFileProperty = selectAudioSmbFile() if ( audioFileProperty.fileSize < 1L ) { // no audio file _getAudioFileStatus.postValue(ProcessStatus.Failed) // failed to get an audio file smb.close() } else { // got an audio file _smbPath.postValue(audioFileProperty.smbPath) _smbSize.postValue(audioFileProperty.fileSize) _getAudioFileStatus.postValue(ProcessStatus.Got) // got an audio file // play audio in MainActivity } } // end of launch if ( ProcessStatus.Failed == smbConnectionStatus.value) { // Failed to connect to SMB return } if (ProcessStatus.Failed == getAudioFileStatus.value) { // Failed to get an audio file return } } // SMBサーバーに接続し、接続オブジェクトをsmbに入れる private fun connectSmb() { try { val prop = Properties() // java.util.Properties prop.setProperty("jcifs.smb.client.minVersion", "SMB202") prop.setProperty("jcifs.smb.client.maxVersion", "SMB300") val baseCxt = BaseContext(PropertyConfiguration(prop)) val auth = baseCxt.withCredentials(NtlmPasswordAuthenticator(domain, user, password)) // connect to SMB server smb = SmbFile(smbUrl, auth) smb.list() // check if SMB-connection succeeded or failed } catch (e1: Exception) { try { smb.close() } catch (e2: Exception) { throw e2 } throw e1 } } /* * ランダムにSMBサーバーの音楽ファイルを1つ選び、そのパスとファイルサイズをAudioFilePropertyデータクラスで返す * data class AudioFileProperty(val smbPath: String, val fileSize: Long) * 失敗した時は AudioFileProperty("",0L) を返す。 * 変数smbは、関数の外でjcifs-ngによって取得したSmbFileインスタンス * audioExtensionは、関数の外で設定された、音声ファイル拡張子集合の文字列 */ fun selectAudioSmbFile(): AudioFileProperty { try { if (smb.isDirectory) { // get all files in dir val smbNormalFiles: MutableList<SmbFile> val tmpSmbNormalFiles: MutableList<SmbFile> = mutableListOf() smbNormalFiles = getNormalSmbFiles(smb, tmpSmbNormalFiles) if (1 > smbNormalFiles.size) { // No file in the directory return AudioFileProperty("",0L) } else if (1 == smbNormalFiles.size) { // only one file in the directory if (!isMatchTail(smbNormalFiles[0].name, audioExtension)) { // No audio file return AudioFileProperty("",0L) } else { // Got one sound file. // Play Audio in main thread return AudioFileProperty(smbNormalFiles[0].path, smbNormalFiles[0].length()) } } else { // smbNormalFiles.size > 1 // in case of plural files. // collect audio files to audioSmbFiles val audioSmbFiles: MutableList<SmbFile> = mutableListOf() for (eachSmbNormalFile in smbNormalFiles) { if (isMatchTail(eachSmbNormalFile.name, audioExtension)) { audioSmbFiles.add(eachSmbNormalFile) } } if (1 > audioSmbFiles.size) { // No audio file return AudioFileProperty("",0L) } else if (1 == audioSmbFiles.size) { // Got one sound file. // PlayAudio in main thread return AudioFileProperty(audioSmbFiles[0].path, audioSmbFiles[0].length()) } else { // audioSmbFiles.size > 1 // select one audio file randomly val countFiles: Int = audioSmbFiles.size val random = Random() val randomNum = random.nextInt(countFiles) // Got one sound file. // PlayAudio in main thread return AudioFileProperty(audioSmbFiles[randomNum].path, audioSmbFiles[randomNum].length()) } } } else if (smb.isFile) { return if (!isMatchTail(smb.name, audioExtension)) { // No audio file AudioFileProperty("",0L) } else { // a normal file // Got one sound file. // PlayAudio in main thread AudioFileProperty(smb.path, smb.length()) } } else { // in case of smb is abnormal return AudioFileProperty("",0L) } } catch (e: Exception) { return AudioFileProperty("",0L) } } override fun onCleared() { try { smb.close() } catch (e: Exception) { } finally { viewModelScope.cancel() super.onCleared() } } /* * SMBサーバーのすべてのノーマルファイルを取得する再帰関数 * パラメータ * givenDir 取得対象のSMBディレクトリパス。 * tmpSmbNormalFiles 空のMutableList<SmbFile>型変数。 * 1つ前の再帰関数実行結果を次の再帰関数に渡すための変数。 * ユーザーは最初にこの関数を起動するので、空のMutableListを与える。 */ private fun getNormalSmbFiles(givenDir: SmbFile, tmpSmbNormalFiles: MutableList<SmbFile>): MutableList<SmbFile> { val childSmbs = givenDir.listFiles() if (childSmbs.isNotEmpty()) { for (eachChild in childSmbs) { if (eachChild.isDirectory) { getNormalSmbFiles(eachChild, tmpSmbNormalFiles) } else if (eachChild.isFile) { tmpSmbNormalFiles.add(eachChild) } else { continue } } } return tmpSmbNormalFiles } /* * target文字列の一番後ろのピリオド以降にconditionStr正規表現文字列が含まれていればtrueを返す */ private fun isMatchTail(target: String, conditionStr: String): Boolean { // target中の一番後ろのピリオド(ファイル拡張子の区切り)の場所を検出する val idxStr = target.lastIndexOf(".") if (idxStr < 0) { return false } // targetから、ピリオドから末尾までの文字列を切り出す val extenStr = target.substring(idxStr) // 切り出した文字列がconditionStrに一致するか調べる val regex = (conditionStr+"$").toRegex(RegexOption.IGNORE_CASE) return regex.containsMatchIn(extenStr) } }
SmbViewModelFactory.kt
// 2021 Sep. 06. import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider class SmbViewModelFactory(val user: String, val password: String, val domain: String, val smbUrl: String): ViewModelProvider.Factory { override fun <T : ViewModel> create(modelClass: Class<T>): T { return SmbViewModel(user, password, domain, smbUrl) as T } }
SmbServerSettingDialogFragment.kt
// 2021 Dec. 04. // 2021 Oct. 30. // 2021 Sep. 20. // Ryuichi Hashimoto import android.app.AlertDialog import android.app.Dialog import android.content.Context import android.os.Bundle import android.text.Editable import android.text.TextWatcher import android.util.Log import android.widget.Button import android.widget.EditText import androidx.fragment.app.DialogFragment class SmbServerSettingDialogFragment: DialogFragment() { // Thank for https://qiita.com/gino_gino/items/9390e1a0ce5e42d16466 interface DialogListener{ public fun onDialogMapReceive(dialog: DialogFragment, smbSettings: MutableMap<String, String>) //Activity側へMutableMapを渡す } var listener:DialogListener? = null // ダイアログ上のEditTextに文字列があるか無いかで buttonの enable を制御する class EditsWatcher(val button: Button, val edit1: EditText, val edit2: EditText, val edit3: EditText, val edit4: EditText): TextWatcher { init { edit1.addTextChangedListener(this) edit2.addTextChangedListener(this) edit3.addTextChangedListener(this) edit4.addTextChangedListener(this) afterTextChanged(null) } override fun beforeTextChanged( s: CharSequence?, start: Int, count: Int, after: Int ) { } //ignore override fun onTextChanged( s: CharSequence?, start: Int, before: Int, count: Int ) { } //ignore override fun afterTextChanged(s: Editable?) { button.isEnabled = edit1.text.toString().trim().isNotEmpty() && edit2.text.toString().trim().isNotEmpty() && edit3.text.toString().trim().isNotEmpty() && edit4.text.toString().trim().isNotEmpty() } } override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { val smbSettings: MutableMap<String, String> = mutableMapOf( "smbDomain" to "", "smbPath" to "", "smbUser" to "", "smbPassword" to "") val builder = AlertDialog.Builder(activity) val inflater = requireActivity().layoutInflater val smbServerSettingView = inflater.inflate(R.layout.dialog_smb_server_setting, null) // Dialog上のEditTextオブジェクトを取得する val edDomain = smbServerSettingView.findViewById<EditText>(R.id.etDomain) val edSmbPath = smbServerSettingView.findViewById<EditText>(R.id.etSmbPath) val edSmbUser = smbServerSettingView.findViewById<EditText>(R.id.etSmbUser) val edSmbPassword = smbServerSettingView.findViewById<EditText>(R.id.etSmbPassword) // 呼び出し元でBundleにセットした値を取得し、Viewにセットする edDomain.setText(requireArguments().getString("smbDomain", "")) edSmbPath.setText(requireArguments().getString("smbRootPath", "")) edSmbUser.setText(requireArguments().getString("smbUser", "")) edSmbPassword.setText(requireArguments().getString("smbPassword", "")) builder.setView(smbServerSettingView) .setTitle("SMB Server Setting") .setPositiveButton("OK") { dialog, id -> with(smbSettings) { put( "smbDomain", edDomain.text.toString() ) put( "smbPath", edSmbPath.text.toString() ) put( "smbUser", edSmbUser.text.toString() ) put( "smbPassword", edSmbPassword.text.toString() ) } // pass data to the Activity having called this dialog listener?.onDialogMapReceive(this,smbSettings) ?: throw Exception("listener is null.") } .setNegativeButton("Cancel") { dialog, id -> // nothing is done } return builder.create().also { dialog -> // dialogを返すと共にdialogにOnShowListenerをセットする dialog.setOnShowListener { val button = dialog.getButton(Dialog.BUTTON_POSITIVE) EditsWatcher(button, edDomain, edSmbPath, edSmbUser, edSmbPassword) } } } // DialogListenerインターフェースをlistenerにセットする // MainActivityで実装したDialogListenerインターフェースのメソッドを利用できるようになる override fun onAttach(context: Context) { super.onAttach(context) try { listener = context as DialogListener }catch (e: Exception){ Log.e("MyApp","CANNOT FIND LISTENER") } } override fun onDetach() { super.onDetach() listener = null } }
ListPlayedFilesDialogFragment.kt
// 2022 Feb. 13. // 2022 Feb. 05. // Ryuichi Hashimoto import android.app.Dialog import android.os.Bundle import androidx.fragment.app.DialogFragment import android.app.AlertDialog import android.widget.ArrayAdapter import android.widget.ListView import android.widget.Toast import java.io.* class ListPlayedFilesDialogFragment: DialogFragment(){ override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { // get Dialog val builder = AlertDialog.Builder(activity) val inflater = requireActivity().layoutInflater val myDialogView = inflater.inflate(R.layout.played_files_list, null) // get array of played files from saved file var playedFiles = "" lateinit var br: BufferedReader try { val savePathFile = File(this.context?.filesDir, getString(R.string.pathHistoryFile)) // savePathFile has only one line. // the line is filePath data in which each datum is separated by ','. if (savePathFile.exists()) { br = BufferedReader(FileReader(savePathFile)) try { playedFiles = br.readLine() } catch(e: IOException) { playedFiles = e.toString() } finally { try { br.close() } catch(e: IOException) { playedFiles = playedFiles + " " + e.toString() } } } } catch (e: Exception){ playedFiles = playedFiles + " " + e.toString() } if (playedFiles == "") { playedFiles = getString(R.string.noHistory) } playedFiles = playedFiles.removeSuffix(",") val playedFileList = playedFiles.split(",").asReversed() // システムに組み込まれた"android.R.layout.simple_list_item_1"を介して、 // playedFileListをArrayAdapterに関連づける val myAdapter = ArrayAdapter(this.requireContext(), android.R.layout.simple_list_item_1, playedFileList) // ArrayAdapterをダイアログ上のレイアウト内のListViewにセットする val listViewFiles = myDialogView.findViewById<ListView>(R.id.listViewFiles) listViewFiles.adapter = myAdapter // display dialog builder.setView(myDialogView) .setTitle("Played Files") .setPositiveButton("Clear") { dialog, which -> lateinit var fos: FileOutputStream try { val savePathFile = File( context?.filesDir, getString(R.string.pathHistoryFile)) fos = FileOutputStream(savePathFile) fos.write(','.code.toInt()) } catch (e: Exception) { Toast.makeText(context,e.toString(), Toast.LENGTH_LONG ).show() } finally { fos.close() } } .setNeutralButton("OK") { _, _ -> // nothing is done } return builder.create() } }