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