rokkonet

PC・Androidソフトウェア・アプリの開発・使い方に関するメモ

JSchを使ってSDカード上のファイルをSFTPでSSHサーバーに送信する android開発

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;
        }
    }
}