rokkonet

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

Android開発︰Android端末をSMBクライアントにするアプリ開発

2026 May. 01.
2026 Apr. 29.

前提

  • Android Studioで新規のプロジェクトを作成する。
    画面テンプレートには"Empty Activity"を選択する。
    Androidのミニマムバージョンは手持ちスマホに合わせて11にした。

  • Android Studioのバージョン

Android Studio Narwhal 3 Feature Drop | 2025.1.3
Build #AI-251.26094.121.2513.14007798, built on August 28, 2025
Runtime version: 21.0.7+-13880790-b1038.58 amd64
VM: OpenJDK 64-Bit Server VM by JetBrains s.r.o.
Toolkit: sun.awt.X11.XToolkit
Linux 6.8.0-110-generic
Ubuntu 24.04.4 LTS; glibc: 2.39
Kotlin plugin: K2 mode
GC: G1 Young Generation, G1 Concurrent GC, G1 Old Generation
Memory: 2048M
Cores: 8
Registry:
  ide.experimental.ui=true
Current Desktop: XFCE


  • app/build.gradle.ktsファイルの内容
plugins {
    alias(libs.plugins.android.application)
    alias(libs.plugins.kotlin.android)
    alias(libs.plugins.kotlin.compose)
}

android {
    namespace = "ne.sy.ro.mylearn01"
    compileSdk = 36

    defaultConfig {
        applicationId = "ne.sy.ro.mylearn01"
        minSdk = 30
        targetSdk = 36
        versionCode = 1
        versionName = "1.0"

        testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
    }

    buildTypes {
        release {
            isMinifyEnabled = false
            proguardFiles(
                getDefaultProguardFile("proguard-android-optimize.txt"),
                "proguard-rules.pro"
            )
        }
    }
    compileOptions {
        sourceCompatibility = JavaVersion.VERSION_11
        targetCompatibility = JavaVersion.VERSION_11
    }
    kotlinOptions {
        jvmTarget = "11"
    }
    buildFeatures {
        compose = true
    }
}

dependencies {

    implementation(libs.androidx.core.ktx)
    implementation(libs.androidx.lifecycle.runtime.ktx)
    implementation(libs.androidx.activity.compose)
    implementation(platform(libs.androidx.compose.bom))
    implementation(libs.androidx.compose.ui)
    implementation(libs.androidx.compose.ui.graphics)
    implementation(libs.androidx.compose.ui.tooling.preview)
    implementation(libs.androidx.compose.material3)
    testImplementation(libs.junit)
    androidTestImplementation(libs.androidx.junit)
    androidTestImplementation(libs.androidx.espresso.core)
    androidTestImplementation(platform(libs.androidx.compose.bom))
    androidTestImplementation(libs.androidx.compose.ui.test.junit4)
    debugImplementation(libs.androidx.compose.ui.tooling)
    debugImplementation(libs.androidx.compose.ui.test.manifest)
}


  • app/src/main/AndroidManifest.xmlの内容
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools">

    <application
        android:allowBackup="true"
        android:dataExtractionRules="@xml/data_extraction_rules"
        android:fullBackupContent="@xml/backup_rules"
        android:icon="@mipmap/ic_launcher"
        android:label="@string/app_name"
        android:roundIcon="@mipmap/ic_launcher_round"
        android:supportsRtl="true"
        android:theme="@style/Theme.MyLearn01">
        <activity
            android:name=".MainActivity"
            android:exported="true"
            android:label="@string/app_name"
            android:theme="@style/Theme.MyLearn01">
            <intent-filter>
                <action android:name="android.intent.action.MAIN" />

                <category android:name="android.intent.category.LAUNCHER" />
            </intent-filter>
        </activity>
    </application>

</manifest>


条件

  • 画面はjetpack Composeで作成する。
  • アプリの動作中にスマホを90度回転してもアプリが正常に動作するものとする。
  • アプリに配置されたメニューに「smbサーバーアドレス」を置き、smbサーバーのIPアドレスをユーザーがセットするようにする。
  • アプリに配置されたメニューに「smbユーザー」を置き、ユーザーがsmbにアクセスするユーザー名とそのパスワードをセットするようにする。
  • アプリに配置されたメニューに「smbディレクトリ」を置き、 それをタップすると、ユーザーがタップして選択できる形でサーバー内のディレクトリを一覧表示し、ユーザーがサーバー内のディレクトリを選択すると、スマホ画面の上部に選択されたディレクトリのsmbサーバーにおけるパスが表示されるようにする。
  • smb接続失敗を防ぐために、smbへのアクセスではDFS(分散ファイルシステム)を無効化する。
  • smb接続失敗を防ぐために、smbへのアクセスではSMB署名を緩和するか、可能であればSMB311まで対応するようにする。
  • 例外・エラー発生時にはその内容をトーストで表示するようにする。


概要

  • build.gradle.ktsへのライブラリ記述は、ローカルにライブラリファイルを置いた記述「implementation(files(...))」とせず、依存関係を自動で解決してくれる記述「implementation("URL:...")」とする。
  • SMBサーバーアクセスにはjcifs-ng ライブラリを使用する。
  • jcifsは、eu.agno3.jcifs版などではなく、主流版であるorg.codelibs版 https://mvnrepository.com/artifact/org.codelibs/jcifs を利用すること。
  • 画面回転に対応するため、状態保持は ViewModel で行い、データの再取得を防ぎつつ正常に動作させる。
  • ネットワーク処理をメインスレッドで行うとアプリがクラッシュ(NetworkOnMainThreadException)するため、非同期処理の coroutinesを利用して viewModelScope.launch(Dispatchers.IO) でバックグラウンド処理を行う。
  • jcifs-ng による通信
    DFS無効化、SMB3.1.1対応、NTLM設定を行う。
    NtlmPasswordAuthenticator を使用して認証情報を作成し、SmbFile でリストを取得する。
    jcifs.smb.client.useRawNTLM = true: NTLM認証の互換性を高める。
    jcifs.smb.client.dfs.disabled = true: 複雑なネットワーク経路でのエラーを回避する。
    jcifs.smb.client.maxVersion = SMB311: 最新のSambaサーバーとの安全な接続を試みる。
    コードのSmbViewModel 内の prop.setProperty にて、dfs.disabled, useRawNTLM を設定し、minVersion/maxVersion でSMB 3.1.1への対応を明示している。

  • Sambaサーバー側の smb.conf で、特定のディレクトリが共有設定(Browseable = Yes)になっていること。

コードを書く前のAndroid Studioでの作業

ライブラリ設定

  • project/app/libsフォルダ内のすべてのjarファイルを取り込む設定

project/app/build.gradle.ktsファイルのdependenciesセクションに次の行を記述する。
implementation(fileTree(mapOf("dir" to "libs", "include" to listOf("*.jar"))))

  • jcifs-ng ライブラリを利用する設定

Maven Repository (org.codelibs https://mvnrepository.com/artifact/org.codelibs/jcibs )の2.1系列の最新バージョンとする。
バージョン2.1.40とした。

project/app/build.gradle.ktsファイルのdependenciesセクションに次の行を記述する。
この記述で自動的にダウンロードされるのでjarファイルの配置は不要。
implementation("org.codelibs:jcifs:2.1.40")

  • ViewModel用ライブラリを利用する設定
    project/app/build.gradle.ktsファイルのdependenciesセクションに次の行を記述する。
    implementation(libs.androidx.lifecycle.viewmodel.compose)


    できあがったproject/app/build.gradle.ktsファイル
plugins {
    alias(libs.plugins.android.application)
    alias(libs.plugins.kotlin.android)
    alias(libs.plugins.kotlin.compose)
}

android {
    namespace = "ne.sy.ro.mylearn01"
    compileSdk = 36

    defaultConfig {
        applicationId = "ne.sy.ro.mylearn01"
        minSdk = 30
        targetSdk = 36
        versionCode = 1
        versionName = "1.0"

        testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
    }

    buildTypes {
        release {
            isMinifyEnabled = false
            proguardFiles(
                getDefaultProguardFile("proguard-android-optimize.txt"),
                "proguard-rules.pro"
            )
        }
    }
    compileOptions {
        sourceCompatibility = JavaVersion.VERSION_11
        targetCompatibility = JavaVersion.VERSION_11
    }
    kotlinOptions {
        jvmTarget = "11"
    }
    buildFeatures {
        compose = true
    }
}

dependencies {

    implementation(libs.androidx.core.ktx)
    implementation(libs.androidx.lifecycle.runtime.ktx)
    implementation(libs.androidx.activity.compose)
    implementation(platform(libs.androidx.compose.bom))
    implementation(libs.androidx.compose.ui)
    implementation(libs.androidx.compose.ui.graphics)
    implementation(libs.androidx.compose.ui.tooling.preview)
    implementation(libs.androidx.compose.material3)
    testImplementation(libs.junit)
    androidTestImplementation(libs.androidx.junit)
    androidTestImplementation(libs.androidx.espresso.core)
    androidTestImplementation(platform(libs.androidx.compose.bom))
    androidTestImplementation(libs.androidx.compose.ui.test.junit4)
    debugImplementation(libs.androidx.compose.ui.tooling)
    debugImplementation(libs.androidx.compose.ui.test.manifest)

    // libsフォルダ内のすべてのjarファイルを取り込む設定
    implementation(fileTree(mapOf("dir" to "libs", "include" to listOf("*.jar"))))

    // jcifs。ファイル配置不要
    implementation("org.codelibs:jcifs:2.1.40")

    // ViewModel用。ファイル配置不要
    implementation(libs.androidx.lifecycle.viewmodel.compose)
}


project/app/build.gradle.ktsファイルを編集したら、Android Studioの右上に出る [Sync Now] をクリックする。

パーミッション設定

app/src/main/AndroidManifest.xmlファイルの<application> タグの前に下記を記述する。

<manifest ...>

    <uses-permission android:name="android.permission.INTERNET" />
    <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />

    <application
        ...


コード

MainActivity.kt

package ne.sy.ro.mylearn01

import android.os.Bundle
import android.widget.Toast
import androidx.activity.ComponentActivity
import androidx.activity.compose.BackHandler
import androidx.activity.compose.setContent
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.compose.foundation.text.selection.SelectionContainer

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            MaterialTheme {
                Surface(
                    modifier = Modifier.fillMaxSize(),
                    color = MaterialTheme.colorScheme.background
                ) {
                    SmbBrowserScreen()
                }
            }
        }
    }
}

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SmbBrowserScreen(viewModel: SmbViewModel = viewModel()) {
    var showMenu by remember { mutableStateOf(false) }
    var showIpDialog by remember { mutableStateOf(false) }
    var showUserDialog by remember { mutableStateOf(false) }
    val context = LocalContext.current

    // システムの戻るボタンの挙動
    BackHandler(enabled = viewModel.canGoBack || viewModel.selectedPath.isNotEmpty()) {
        if (viewModel.selectedPath.isNotEmpty()) {
            viewModel.selectedPath = ""
        } else {
            viewModel.goBack()
        }
    }

    Scaffold(
        topBar = {
            TopAppBar(
                title = {
                    // --- 選択中の時だけパスを表示し、確定後は何も表示しない ---
                    if (viewModel.selectedPath.isEmpty()) {
                        Text(
                            text = viewModel.currentPath,
                            style = MaterialTheme.typography.bodySmall,
                            maxLines = 1
                        )
                    }
                },
                navigationIcon = {
                    // 選択中の時、かつ戻れる履歴がある時だけ矢印を表示
                    if (viewModel.selectedPath.isEmpty() && viewModel.canGoBack) {
                        IconButton(onClick = { viewModel.goBack() }) {
                            Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "戻る")
                        }
                    }
                },
                actions = {
                    IconButton(onClick = { showMenu = true }) {
                        Icon(Icons.Default.MoreVert, contentDescription = "メニュー")
                    }
                    DropdownMenu(expanded = showMenu, onDismissRequest = { showMenu = false }) {
                        DropdownMenuItem(
                            text = { Text("smbサーバーアドレス") },
                            onClick = { showMenu = false; showIpDialog = true }
                        )
                        DropdownMenuItem(
                            text = { Text("smbユーザー") },
                            onClick = { showMenu = false; showUserDialog = true }
                        )
                        DropdownMenuItem(
                            text = { Text("トップへ戻る") },
                            onClick = { showMenu = false; viewModel.goToTop() }
                        )
                        DropdownMenuItem(
                            text = { Text("更新") },
                            onClick = { showMenu = false; viewModel.refresh() }
                        )
                    }
                }
            )
        },
        floatingActionButton = {
            if (viewModel.selectedPath.isEmpty() &&
                viewModel.currentPath != "smb://" &&
                viewModel.currentPath != "smb://${viewModel.serverIp}/") {
                ExtendedFloatingActionButton(
                    onClick = {
                        viewModel.selectAndReturn()
                        Toast.makeText(context, "パスを決定しました", Toast.LENGTH_SHORT).show()
                    },
                    icon = { Icon(Icons.Default.Check, contentDescription = null) },
                    text = { Text("ここにする!") }
                )
            }
        }
    ) { padding ->
        Column(modifier = Modifier.padding(padding)) {

            if (viewModel.selectedPath.isNotEmpty()) {
                Card(
                    modifier = Modifier
                        .fillMaxWidth()
                        .padding(16.dp),
                    colors = CardDefaults.cardColors(
                        containerColor = MaterialTheme.colorScheme.secondaryContainer
                    )
                ) {
                    Column(modifier = Modifier.padding(16.dp)) {
                        Text(
                            text = "決定されたディレクトリ:",
                            style = MaterialTheme.typography.labelMedium,
                            color = MaterialTheme.colorScheme.primary
                        )
                        Spacer(modifier = Modifier.height(8.dp))
                        SelectionContainer {
                            Text(
                                text = viewModel.selectedPath,
                                style = MaterialTheme.typography.bodyLarge
                            )
                        }
                    }
                }
            } else {
                LazyColumn {
                    items(viewModel.directoryList) { dir ->
                        ListItem(
                            headlineContent = { Text(dir) },
                            modifier = Modifier.clickable {
                                viewModel.loadDirectory("${viewModel.currentPath}$dir")
                            }
                        )
                        HorizontalDivider()
                    }
                }
            }
        }

        if (showIpDialog) {
            SettingDialog(
                title = "サーバー設定",
                value = viewModel.serverIp,
                onConfirm = {
                    viewModel.serverIp = it
                    viewModel.saveSettings()
                    showIpDialog = false
                },
                onDismiss = { showIpDialog = false }
            )
        }

        if (showUserDialog) {
            UserSettingDialog(
                currentUsername = viewModel.username,
                currentPassword = viewModel.password,
                onConfirm = { u, p ->
                    viewModel.username = u
                    viewModel.password = p
                    viewModel.saveSettings()
                    showUserDialog = false
                },
                onDismiss = { showUserDialog = false }
            )
        }
    }
}

@Composable
fun SettingDialog(title: String, value: String, onConfirm: (String) -> Unit, onDismiss: () -> Unit) {
    var text by remember { mutableStateOf(value) }
    AlertDialog(
        onDismissRequest = onDismiss,
        title = { Text(title) },
        text = {
            OutlinedTextField(
                value = text,
                onValueChange = { text = it },
                label = { Text("IPアドレス") },
                singleLine = true
            )
        },
        confirmButton = { TextButton(onClick = { onConfirm(text) }) { Text("保存") } },
        dismissButton = { TextButton(onClick = onDismiss) { Text("キャンセル") } }
    )
}

@Composable
fun UserSettingDialog(currentUsername: String, currentPassword: String, onConfirm: (String, String) -> Unit, onDismiss: () -> Unit) {
    var u by remember { mutableStateOf(currentUsername) }
    var p by remember { mutableStateOf(currentPassword) }
    AlertDialog(
        onDismissRequest = onDismiss,
        title = { Text("ユーザー設定") },
        text = {
            Column {
                OutlinedTextField(value = u, onValueChange = { u = it }, label = { Text("ユーザー名") }, singleLine = true)
                Spacer(modifier = Modifier.height(8.dp))
                OutlinedTextField(value = p, onValueChange = { p = it }, label = { Text("パスワード") }, singleLine = true)
            }
        },
        confirmButton = { TextButton(onClick = { onConfirm(u, p) }) { Text("保存") } },
        dismissButton = { TextButton(onClick = onDismiss) { Text("キャンセル") } }
    )
}



SmbViewModel.kt

package ne.sy.ro.mylearn01

import android.app.Application
import android.content.Context
import android.widget.Toast
import androidx.compose.runtime.*
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import jcifs.context.BaseContext
import jcifs.config.PropertyConfiguration
import jcifs.smb.NtlmPasswordAuthenticator
import jcifs.smb.SmbFile
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.util.Properties

class SmbViewModel(application: Application) : AndroidViewModel(application) {
    // 永続化用のSharedPreferences
    private val prefs = application.getSharedPreferences("smb_settings", Context.MODE_PRIVATE)

    // --- 状態管理(State) ---
    var serverIp by mutableStateOf(prefs.getString("server_ip", "") ?: "")
    var username by mutableStateOf(prefs.getString("username", "") ?: "")
    var password by mutableStateOf(prefs.getString("password", "") ?: "")

    // 現在閲覧中のパス(前回の場所を復元)
    var currentPath by mutableStateOf(prefs.getString("last_path", "smb://") ?: "smb://")

    // 決定されたパス(これが空でない時、UIは「決定画面」モードになる)
    var selectedPath by mutableStateOf(prefs.getString("selected_path", "") ?: "")

    // 取得したディレクトリ一覧
    var directoryList = mutableStateListOf<String>()

    // 戻る機能のための履歴スタック
    private val pathHistory = mutableStateListOf<String>()

    // 戻るボタンを有効にする条件
    val canGoBack: Boolean get() = pathHistory.isNotEmpty()

    init {
        // 起動時、サーバー設定があれば前回の場所(またはルート)を読み込む
        if (serverIp.isNotEmpty()) {
            loadDirectory(currentPath)
        }
    }

    /**
     * SMB通信のためのコンテキスト設定
     * エラー対策としてSMB2.0.2〜3.1.1、署名無効、拡張セキュリティ有効を設定
     */
    private fun getContext(): BaseContext {
        val prop = Properties()
        prop.setProperty("jcifs.smb.client.dfs.disabled", "true")
        prop.setProperty("jcifs.smb.client.useRawNTLM", "true")
        prop.setProperty("jcifs.smb.client.useExtendedSecurity", "true")
        prop.setProperty("jcifs.smb.client.signingPreferred", "false")
        prop.setProperty("jcifs.smb.client.signingEnforced", "false")
        prop.setProperty("jcifs.smb.client.minVersion", "SMB202")
        prop.setProperty("jcifs.smb.client.maxVersion", "SMB311")
        return BaseContext(PropertyConfiguration(prop))
    }

    /**
     * 設定(IP, ユーザー, パス, 現在のパス, 決定されたパス)を本体に保存
     */
    fun saveSettings() {
        prefs.edit().apply {
            putString("server_ip", serverIp)
            putString("username", username)
            putString("password", password)
            putString("last_path", currentPath)
            putString("selected_path", selectedPath)
            apply()
        }
    }

    /**
     * 現在のディレクトリを決定し、メイン画面(ルート)の状態に戻る
     */
    fun selectAndReturn() {
        selectedPath = currentPath
        saveSettings()
        // 画面上の表示パスをサーバーのルートに戻し、履歴もクリアする
        pathHistory.clear()
        currentPath = "smb://${serverIp}/"
    }

    /**
     * ディレクトリを読み込む
     */
    fun loadDirectory(path: String? = null, isBackNavigation: Boolean = false) {
        // パスが指定されなければルート、指定されればそのパスを使用
        val targetPath = path ?: "smb://${serverIp}/"

        viewModelScope.launch {
            try {
                val list = withContext(Dispatchers.IO) {
                    val auth = NtlmPasswordAuthenticator(null, username, password)
                    val context = getContext().withCredentials(auth)
                    SmbFile(targetPath, context).use { file ->
                        file.listFiles()
                            .filter { it.isDirectory }
                            .map { it.name }
                    }
                }

                // 正常に取得できた場合:戻る操作でないなら現在のパスを履歴に追加
                if (!isBackNavigation && currentPath != targetPath && currentPath != "smb://") {
                    pathHistory.add(currentPath)
                }

                directoryList.clear()
                directoryList.addAll(list)
                currentPath = targetPath

                // 成功した時のパスを「最後の場所」として記憶
                saveSettings()

            } catch (e: Exception) {
                withContext(Dispatchers.Main) {
                    Toast.makeText(getApplication(), "エラー: ${e.message}", Toast.LENGTH_LONG).show()
                }
            }
        }
    }

    /**
     * 履歴を一つ戻る
     */
    fun goBack() {
        if (pathHistory.isNotEmpty()) {
            val previousPath = pathHistory.removeAt(pathHistory.size - 1)
            loadDirectory(previousPath, isBackNavigation = true)
        }
    }

    /**
     * 決定状態を解除し、サーバーのトップ階層へ戻る
     */
    fun goToTop() {
        selectedPath = ""
        pathHistory.clear()
        loadDirectory(null, isBackNavigation = true)
        saveSettings()
    }

    /**
     * 決定状態を解除し、現在のパスを再読み込みする
     */
    fun refresh() {
        selectedPath = ""
        loadDirectory(currentPath)
        saveSettings()
    }
}



コードのエラーの有無確認

Android Studio内で

「メニュー -> Build -> Assemble app Run Configuration」を実行する。
「メニュー -> View -> Tool Windows -> Build」で結果を確認する。

スマホ実機でアプリの実行確認

通信用USBケーブルでAndroid StudioとAndroid端末をつなぐ。
Android端末で 設定 -> システム -> 開発者向けオプション -> 「USBデバッグを有効」にする。
Android端末のUSB設定で「ファイル転送」にする。
Android Studioのメニュー -> Run -> Run app