2020 May 08.
2020 May 05.
ポイント
・インターネット通信許可(normalパーミッション)
・SDカード読み取り許可取得(dangerousパーミッション)
・SDカードのパス取得
・SDカード上のファイル取得
・AsyncTaskでの通信
・JSch利用
JSchの手順
SFTPの手順を定義したAsyncTaskの継承クラスMySftpTaskを書き、実行する
SSHに必要なデータをMySftpTaskに与える
SSHサーバーIPアドレス
ポート番号
SSHクライアントユーザー名
秘密鍵フィルパス
パスワード/パスフレーズ
クライアント上の送信元ファイル
ファイルを受信するサーバーの送信先ディレクトリ
チャンネルタイプ(SFTP)
MySftpTask#doInBackground()内で下記を実行する
・JSchのSessionの開始 connectSession()
・SFTPのChannelの開始 connectChannelSftp()
・ファイル送信 putFile()
・Session・Channelの終了 disconnect()
connectSession() の内容
・クライアント側のknownHostsチェックを行わない設定 JSch.setConfigI()
・秘密鍵・パスフレーズ方式設定 JSch#addIdentity()
・セッション取得 JSch#getSession(): Session
・パスワード方式を可とし、パスワードをセット Session#setPassword()
・UserInfoインターフェースを実装したクラスのインスタンスを
Sessionにセット Session#setUserInfo()
・セッションをコネクト Session#connect()
・connectSession()の返り値としてSessionを返却
connectChannelSftp() の内容
・SFTPチャンネルを開きChannelSftpを
取得 Session#openChannel("sftp"): ChannelSftp
・ChannelSftpからチャンネルを接続 ChannelSftp#connect()
・connectChannelSftp()の返り値としてChannelSftpを返却
putFile(channel: ChannelSftp?, inFile: File , destPath: String ) の内容
・引数destPathは、SSHホームディレクトリからの相対ファイルパス
・SSHサーバーのカレントリモートディレクトリをSSHホームディレクトリに
変更 ChannelSftp#cd(channel.home)
・ファイルを送信 ChannelSftp#put( SRC_FILE_PATH, DEST_FILE_NAME )
[put()に指定するファイル]
SRC_FILE_PATH: 絶対ファイルパスもしくは相対ファイルパスの
付いた送信元ファイル名
DEST_FILE_NAME: サーバーの絶対パス付きファイル名もしくは
カレントリモートディレクトリからの相対パス
付きファイル名
disconnect() の内容
・SFTPチャンネルを切断 Channel#disconnect()
・セッションのコネクションを切断 Session#disconnect()
プログラム ソース
[AndroidManifest.xml]
permission.INTERNET、permission.WRITE_EXTERNAL_STORAGE を記述する
<?xml version="1.0" encoding="utf-8"?> <manifest xmlns:android="http://schemas.android.com/apk/res/android" package="YOUR.ANDROID.PACKAGE.PROJECT"> <uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.WRITE_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/AppTheme"> <activity android:name=".MainActivity"> <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"> <Button android:id="@+id/buttonStartSftp" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginBottom="16dp" android:enabled="false" android:text="Start SFTP" app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintHorizontal_bias="0.497" app:layout_constraintStart_toStartOf="parent" /> <Button android:id="@+id/buttonGetFile" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginBottom="28dp" android:text="Select File" app:layout_constraintBottom_toTopOf="@+id/buttonStartSftp" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintHorizontal_bias="0.493" app:layout_constraintStart_toStartOf="parent" /> <TextView android:id="@+id/textViewSdPath" android:layout_width="284dp" android:layout_height="35dp" android:layout_marginBottom="24dp" android:text="SDカードのパス" android:textIsSelectable="true" app:layout_constraintBottom_toTopOf="@+id/textViewFilePathTitle" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintHorizontal_bias="0.222" app:layout_constraintStart_toStartOf="parent" /> <TextView android:id="@+id/textViewFilePath" android:layout_width="294dp" android:layout_height="116dp" android:layout_marginBottom="28dp" android:textIsSelectable="true" app:layout_constraintBottom_toTopOf="@+id/buttonGetFile" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" /> <TextView android:id="@+id/textViewSdPathTitle" android:layout_width="121dp" android:layout_height="21dp" android:layout_marginTop="80dp" android:text="SDカードのパス" app:layout_constraintBottom_toTopOf="@+id/textViewSdPath" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintHorizontal_bias="0.04" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="parent" app:layout_constraintVertical_bias="0.8" /> <TextView android:id="@+id/textViewFilePathTitle" android:layout_width="215dp" android:layout_height="21dp" android:layout_marginBottom="8dp" android:text="選択したファイルのフルパス" app:layout_constraintBottom_toTopOf="@+id/textViewFilePath" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintHorizontal_bias="0.123" app:layout_constraintStart_toStartOf="parent" /> </androidx.constraintlayout.widget.ConstraintLayout>
[MainActivity.kt]
package YOUR.ANDROID.PACKAGE.PROJECT // 2020 May 08. // 2020 May 02. // Ryuichi Hashimoto /* * get a file-path by the file-manager in Android-device. * send the file to ssh-server by SFTP. */ import android.Manifest import android.annotation.SuppressLint import android.content.Context import android.content.Intent import android.content.pm.PackageManager import android.os.AsyncTask import android.os.Bundle import android.view.View import android.widget.Toast import androidx.appcompat.app.AppCompatActivity import androidx.core.app.ActivityCompat import com.jcraft.jsch.* import kotlinx.android.synthetic.main.activity_main.* import java.io.File import java.io.UnsupportedEncodingException import java.net.URLDecoder import java.util.* class MainActivity : AppCompatActivity() { private lateinit var strSdTailDir: String private lateinit var strSdFullDir: String private lateinit var srcFile: File private val GET_FILE_CODE: Int = 1100 private val REQUEST_PERMISSION_EX_STORAGE = 2100 override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_main) // 外部記憶装置へのパーミッション要求 ActivityCompat.requestPermissions(this, arrayOf(Manifest.permission.WRITE_EXTERNAL_STORAGE), REQUEST_PERMISSION_EX_STORAGE) // ButtonGetFileにオンクリックリスナをセット buttonGetFile.setOnClickListener { onClick(buttonGetFile) } // ButtonStartSftpにオンクリックリスナをセット buttonStartSftp.setOnClickListener { buttonStartSftp.isEnabled = false sftp() } } /* * パーミッションリクエストの結果処理 */ override fun onRequestPermissionsResult( requestCode: Int, permissions: Array<String>, grantResults: IntArray) { if (requestCode == REQUEST_PERMISSION_EX_STORAGE) { // 外部記憶装置へのパーミッションが許可された if (grantResults[0] == PackageManager.PERMISSION_GRANTED) { Toast.makeText(applicationContext, "ex-storage-permission true", Toast.LENGTH_LONG).show() // 外部記憶装置のパスを取得 strSdFullDir = getFullPathExtStorage(this) strSdTailDir = strSdFullDir.replace("^.+/".toRegex(),"") textViewSdPath.text = strSdFullDir } } } private fun onClick(view: View?) { // ButtonGetFileがクリックされた時 if ( buttonGetFile == view ) { val getFileIntent = Intent(Intent.ACTION_GET_CONTENT) getFileIntent.type = "*/*" // display all files getFileIntent.addCategory(Intent.CATEGORY_OPENABLE) // 端末のファイラを起動する startActivityForResult(getFileIntent, GET_FILE_CODE) } } /* * 別のアクティビティ・アプリから戻って来た時 */ override fun onActivityResult(requestCode: Int , resultCode: Int, data: Intent?) { super.onActivityResult(requestCode, resultCode, data ) try { if (requestCode == GET_FILE_CODE && resultCode == RESULT_OK) { // ファイラでファイルパスを取得した時 if (null == data) { Toast.makeText(this, "False on getting file.",Toast.LENGTH_LONG).show() return } val filePath: String = data.dataString val decodedFilePath: String = URLDecoder.decode(filePath, "utf-8"). replace("^.*${strSdTailDir}".toRegex(), strSdFullDir). replace(":", "/") if (!decodedFilePath.contains(strSdTailDir)) { textViewFilePath.text = "" Toast.makeText(this, "Use proper file-select-app to get file-name", Toast.LENGTH_LONG).show() return } textViewFilePath.text = decodedFilePath srcFile = File(decodedFilePath) buttonStartSftp.isEnabled = true } } catch (e: UnsupportedEncodingException) { Toast.makeText(this, "exception on get filename", Toast.LENGTH_LONG).show() } } /* * 外部記憶装置のパスを取得する */ private fun getFullPathExtStorage(appContext: Context?): String { // 端末の外部ストーリッジのパスの一覧を取得する val sdCardFilesDirPaths = SdCardDirPaths.getSdCardFilesDirPathListForLollipop(appContext) // sort sdCardFilesDirPaths order by shorter length of path Collections.sort(sdCardFilesDirPaths, CompStringLength()) // get top path in array of sdCardFilesDirPaths val externalPath = sdCardFilesDirPaths[0] // get complete full path return externalPath.replace("/Android.*$".toRegex(), "") } /* * SFTPを実行する関数 */ private fun sftp() { // **** set data for sftp below **** val ServerIp = "" // "SSH.SERVER.URL.OR.IP" val Port: Int = // Port Number of SSH-Server val User = "" // "UserName" val PrivKeyFilePath = strSdFullDir + "/EXAMPLE/ANDROID/SSH/id_rsa" // if IdentityKeyPath is illegal, password authentication is used. val SshPass = "" // "SSH password or passphrase" var DestParentPathFromSshHome = "" // "SSH/SERVER/DEST/DIR" // source file is already kept in srcFile(type: File) // **** set data for sftp above **** // if the top of DestParentPathFromSshHome is "/", remove it DestParentPathFromSshHome = DestParentPathFromSshHome.replace("^/".toRegex(), "") val CHANNEL_TYPE = "sftp" // Channel接続タイプ val parentContext = applicationContext // run AsyncTask for SFTP val sftpTask = MySftpTask( ServerIp, Port, User, PrivKeyFilePath, SshPass, srcFile, DestParentPathFromSshHome, CHANNEL_TYPE, parentContext ) sftpTask.execute() } /* * SFTPを行うAsyncTaskクラスを定義 */ @SuppressLint("StaticFieldLeak") inner class MySftpTask(): AsyncTask<Void, Void, String>() { private lateinit var ServerIp: String private var Port: Int = 0 private lateinit var User: String private lateinit var PrivKeyFilePath: String private lateinit var SshPass: String private lateinit var DestParentPathFromSshHome: String private lateinit var CHANNEL_TYPE: String private lateinit var srcFile: File private lateinit var parentContext: Context constructor(ServerIp: String, Port: Int, User: String, PrivKeyFilePath: String, SshPass: String, srcFile: File, DestParentPathFromSshHome: String, CHANNEL_TYPE: String, parentContext: Context) : this() { this.ServerIp = ServerIp this.Port = Port this.User = User this.PrivKeyFilePath = PrivKeyFilePath this.SshPass = SshPass this.srcFile = srcFile this.DestParentPathFromSshHome = DestParentPathFromSshHome this.CHANNEL_TYPE = CHANNEL_TYPE this.parentContext = parentContext } override fun doInBackground(vararg params: Void?): String { var sftpSession: Session? = null var sftpChannel: ChannelSftp? = null // connect session, channel try { sftpSession = connectSession(ServerIp, Port, User, PrivKeyFilePath, SshPass) sftpChannel = connectChannelSftp(sftpSession, CHANNEL_TYPE) // upload val destPath: String = DestParentPathFromSshHome + "/" + srcFile.name try { putFile(sftpChannel, srcFile, destPath) } catch (e: SftpException) { try { // 例外発生時はアップロードされたファイルを削除する deleteFiles(sftpChannel, srcFile.name) disconnect(sftpSession, sftpChannel) return ("Failed on putFile(). $e") } catch (e1: SftpException) { disconnect(sftpSession, sftpChannel) return ("Failed to delete remote files. $e1") } } // } catch(e: (JSchException || SftpException)) { <- how to describe? } catch (e: Exception) { if (sftpSession != null) { disconnect(sftpSession, sftpChannel) } return ("Failed to connect sftp. $e") } finally { // disconnect channel, session disconnect(sftpSession, sftpChannel) } return "SFTP succeeded." } override fun onPostExecute(result: String) { Toast.makeText( parentContext, result, Toast.LENGTH_LONG ).show() buttonStartSftp.isEnabled = true } } /* * JSchのSessionを開始する */ @Throws(JSchException::class) private fun connectSession(ServerIp: String, Port: Int, User: String, PrivKeyFilePath: String, SshPass: String): Session { var session: Session? = null val myJSch = JSch() // クライアント側のknownHostsチェックを行わない val config = Hashtable<String, String>() config["StrictHostKeyChecking"] = "no" JSch.setConfig(config) // パスフレーズ・秘密鍵方式 val privKeyFile = File(PrivKeyFilePath) if (privKeyFile.exists()) { myJSch.addIdentity(PrivKeyFilePath, SshPass) } // セッション取得 session = myJSch.getSession(User, ServerIp, Port) // パスワード方式でも可とする session.setPassword(SshPass) val userInfo: UserInfo = SftpUserInfo() session.userInfo = userInfo // コネクションを張る session.connect() return session } /** * SFTPのChannelを開始する * * @param session * 開始されたSession情報 */ private fun connectChannelSftp(session: Session, CHANNEL_TYPE:String): ChannelSftp? { val channel: ChannelSftp = session.openChannel(CHANNEL_TYPE) as ChannelSftp try { channel.connect() } catch (e: JSchException ) { return null } return channel } /* * ファイルアップロード * inFile: File型の送信元ファイル * destPath: 送信先SSHサーバー側のパス付きファイル名 */ private fun putFile(channel: ChannelSftp?, inFile: File , destPath: String ): Boolean { if (null == channel) { throw Exception("channel is null on putFile()") return false } channel.cd(channel.home) channel.put( inFile.absolutePath, destPath) // confirm existance of destFile try { channel.lstat(destPath) } catch (e: SftpException) { return false } return true } /* * サーバー上のファイルを削除する */ private fun deleteFiles(channel: ChannelSftp?, fileName: String) { if (null != channel) { channel.rm(fileName) } } /** * Session・Channelの終了 * * @param session * 開始されたSession情報 * @param channels * 開始されたChannel情報.複数指定可能 */ private fun disconnect(session: Session?, vararg channels: Channel?) { if (channels != null) { for (c in channels) { c?.disconnect() } } if (session != null) { session.disconnect() } } /* * SFTPに接続するユーザ情報を保持するクラス */ private class SftpUserInfo : UserInfo { override fun getPassword(): String? { return null } override fun promptPassword(arg0: String): Boolean { return true } override fun promptPassphrase(arg0: String): Boolean { return true } override fun promptYesNo(arg0: String): Boolean { return true } override fun showMessage(arg0: String) {} override fun getPassphrase(): String? { return null } } }
[SdCardDirPaths.java]
package YOUR.ANDROID.PACKAGE.PROJECT import android.annotation.TargetApi; import android.content.Context; import android.os.Build; import android.os.Environment; import java.io.File; import java.util.ArrayList; import java.util.List; public class SdCardDirPaths { /** * SDカードのfilesディレクトリパスのリストを取得する。 * Android5.0以上対応。 * * @param context コンテクスト * @return List<String>: SDカードのfilesディレクトリパスのリスト */ @TargetApi(Build.VERSION_CODES.LOLLIPOP) public static List<String> getSdCardFilesDirPathListForLollipop(Context context) { List<String> sdCardFilesDirPathList = new ArrayList<>(); // 外部ストーリッジのディレクトリのFile型配列を取得 // getExternalFilesDirs()はAndroid4.4から利用可能 File[] dirArr = context.getExternalFilesDirs(null); for (File dir : dirArr) { if (dir != null) { String path = dir.getAbsolutePath(); // isExternalStorageRemovableはAndroid5.0から利用できるAPI。 // 取り外し可能かどうか(SDカードかどうか)を判定している。 if (Environment.isExternalStorageRemovable(dir)) { // 取り外し可能であればSDカード。 // このパスをパスリストに加える if (!sdCardFilesDirPathList.contains(path)) { sdCardFilesDirPathList.add(path); } } } } return sdCardFilesDirPathList; } }
[CompStringLength.java]
package YOUR.ANDROID.PACKAGE.PROJECT import java.util.Comparator; public class CompStringLength implements Comparator<String> { /* * return 1: String first is longer than String second * return -1: String second is longer than String first * return 0: equal */ @Override public int compare(String first, String second){ //null評価 //両方nullなら等価とする if(first == null && second == null){ return 0; } //片方nullなら、nullを小さいとする。 if(first == null){ return -1; }else if(second == null){ return 1; } //idの文字列長でソート。文字列数がが小さい順に並べる。 if (first.length() > second.length()) { return 1; }else if (first.length() < second.length()) { return -1; }else { return 0; } } }