commit e1861a2967584a7d7c10fdbd3f1195fa5f7be56b Author: shuiyu1123 Date: Sat May 2 12:30:49 2026 +0800 feat: MiMo Studio Android v1.0.0 - OpenAI-compatible API client for Xiaomi MiMo platform - Chat completions with streaming support - Model list browser - Multi-conversation management - Settings: API key, base URL, temperature, max tokens - Material Design 3 UI API Endpoints: - GET /v1/models - List available models - GET /v1/models/{id} - Get model details - POST /v1/chat/completions - Chat (stream & non-stream) - POST /v1/completions - Text completions (stream & non-stream) diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e405e9a --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +*.iml +.gradle +/local.properties +/.idea +.DS_Store +/build +/captures +.externalNativeBuild +.cxx +local.properties +/tmp diff --git a/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 0000000..0e82ffd --- /dev/null +++ b/app/build.gradle.kts @@ -0,0 +1,66 @@ +plugins { + id("com.android.application") + id("org.jetbrains.kotlin.android") + id("org.jetbrains.kotlin.plugin.serialization") version "2.0.21" +} + +android { + namespace = "com.mimo.studio" + compileSdk = 35 + + defaultConfig { + applicationId = "com.mimo.studio" + minSdk = 26 + targetSdk = 35 + versionCode = 1 + versionName = "1.0.0" + } + + buildTypes { + release { + isMinifyEnabled = true + isShrinkResources = true + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + kotlinOptions { + jvmTarget = "17" + } + + buildFeatures { + viewBinding = true + } +} + +dependencies { + implementation("androidx.core:core-ktx:1.15.0") + implementation("androidx.appcompat:appcompat:1.7.0") + implementation("com.google.android.material:material:1.12.0") + implementation("androidx.constraintlayout:constraintlayout:2.2.1") + implementation("androidx.recyclerview:recyclerview:1.4.0") + implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:2.8.7") + implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.7") + implementation("androidx.swiperefreshlayout:swiperefreshlayout:1.1.0") + + // Retrofit + OkHttp + implementation("com.squareup.retrofit2:retrofit:2.11.0") + implementation("com.squareup.retrofit2:converter-gson:2.11.0") + implementation("com.squareup.okhttp3:okhttp:4.12.0") + implementation("com.squareup.okhttp3:logging-interceptor:4.12.0") + implementation("com.squareup.okhttp3:okhttp-sse:4.12.0") + + // Gson + implementation("com.google.code.gson:gson:2.11.0") + + // Coroutines + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0") +} diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 0000000..65d549e --- /dev/null +++ b/app/proguard-rules.pro @@ -0,0 +1,5 @@ +-dontwarn okhttp3.** +-dontwarn okio.** +-dontwarn javax.annotation.** +-keep class com.mimo.studio.model.** { *; } +-keep class com.mimo.studio.api.** { *; } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..7865050 --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/java/com/mimo/studio/MainActivity.kt b/app/src/main/java/com/mimo/studio/MainActivity.kt new file mode 100644 index 0000000..e593924 --- /dev/null +++ b/app/src/main/java/com/mimo/studio/MainActivity.kt @@ -0,0 +1,266 @@ +package com.mimo.studio + +import android.content.Intent +import android.os.Bundle +import android.view.View +import android.widget.* +import androidx.appcompat.app.ActionBarDrawerToggle +import androidx.appcompat.app.AppCompatActivity +import androidx.core.view.GravityCompat +import androidx.drawerlayout.widget.DrawerLayout +import androidx.lifecycle.lifecycleScope +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import com.google.android.material.appbar.MaterialToolbar +import com.google.android.material.button.MaterialButton +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.google.android.material.navigation.NavigationView +import com.google.android.material.switchmaterial.SwitchMaterial +import com.google.android.material.textfield.TextInputEditText +import com.mimo.studio.api.RetrofitClient +import com.mimo.studio.model.ChatMessage +import com.mimo.studio.model.ModelInfo +import com.mimo.studio.ui.ChatAdapter +import com.mimo.studio.ui.ModelsActivity +import com.mimo.studio.viewmodel.MainViewModel +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch + +class MainActivity : AppCompatActivity() { + + private lateinit var viewModel: MainViewModel + private lateinit var chatAdapter: ChatAdapter + private lateinit var drawerLayout: DrawerLayout + private lateinit var recyclerChat: RecyclerView + private lateinit var etMessage: EditText + private lateinit var btnSend: ImageButton + private lateinit var progressBar: ProgressBar + private lateinit var tvStreaming: TextView + private lateinit var spinnerModel: Spinner + + private var modelNames = listOf("xiaomi/mimo-v2-flash", "xiaomi/mimo-v2-pro", "xiaomi/mimo-v2-omni", "xiaomi/mimo-v2.5-pro", "xiaomi/mimo-v2.5") + private var modelDisplayNames = listOf("MiMo-V2-Flash", "MiMo-V2-Pro", "MiMo-V2-Omni", "MiMo-V2.5-Pro", "MiMo-V2.5") + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(R.layout.activity_main) + + viewModel = MainViewModel(application) + + initViews() + setupDrawer() + setupChat() + setupModelSpinner() + observeState() + + // Check if API key is configured + if (viewModel.getApiKey().isBlank()) { + showSettingsDialog() + } else { + viewModel.fetchModels() + } + } + + private fun initViews() { + drawerLayout = findViewById(R.id.drawerLayout) + recyclerChat = findViewById(R.id.recyclerChat) + etMessage = findViewById(R.id.etMessage) + btnSend = findViewById(R.id.btnSend) + progressBar = findViewById(R.id.progressBar) + tvStreaming = findViewById(R.id.tvStreaming) + spinnerModel = findViewById(R.id.spinnerModel) + } + + private fun setupDrawer() { + val toolbar = findViewById(R.id.toolbar) + val toggle = ActionBarDrawerToggle( + this, drawerLayout, toolbar, + R.string.app_name, R.string.app_name + ) + drawerLayout.addDrawerListener(toggle) + toggle.syncState() + + val navView = findViewById(R.id.navView) + navView.setNavigationItemSelectedListener { item -> + drawerLayout.closeDrawer(GravityCompat.START) + when (item.itemId) { + R.id.nav_new_chat -> viewModel.newConversation() + R.id.nav_models -> { + startActivity(Intent(this, ModelsActivity::class.java)) + } + R.id.nav_settings -> showSettingsDialog() + } + true + } + } + + private fun setupChat() { + chatAdapter = ChatAdapter() + recyclerChat.layoutManager = LinearLayoutManager(this).apply { + stackFromEnd = true + } + recyclerChat.adapter = chatAdapter + + btnSend.setOnClickListener { + val text = etMessage.text.toString().trim() + if (text.isNotEmpty()) { + viewModel.sendMessage(text) + etMessage.text.clear() + } + } + } + + private fun setupModelSpinner() { + val adapter = ArrayAdapter(this, android.R.layout.simple_spinner_item, modelDisplayNames) + adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item) + spinnerModel.adapter = adapter + + spinnerModel.onItemSelectedListener = object : AdapterView.OnItemSelectedListener { + override fun onItemSelected(parent: AdapterView<*>?, view: View?, pos: Int, id: Long) { + viewModel.setSelectedModel(modelNames[pos]) + } + override fun onNothingSelected(parent: AdapterView<*>?) {} + } + } + + private fun observeState() { + lifecycleScope.launch { + viewModel.currentConversation.collectLatest { conv -> + if (conv != null) { + chatAdapter.submitList(conv.messages.toList()) + if (conv.messages.isNotEmpty()) { + recyclerChat.post { + recyclerChat.smoothScrollToPosition(conv.messages.size - 1) + } + } + } + } + } + + lifecycleScope.launch { + viewModel.isLoading.collectLatest { loading -> + progressBar.visibility = if (loading) View.VISIBLE else View.GONE + btnSend.isEnabled = !loading + } + } + + lifecycleScope.launch { + viewModel.isStreaming.collectLatest { streaming -> + tvStreaming.visibility = if (streaming) View.VISIBLE else View.GONE + } + } + + lifecycleScope.launch { + viewModel.streamingContent.collectLatest { content -> + if (content.isNotEmpty()) { + tvStreaming.text = content + chatAdapter.updateStreamingContent(content) + recyclerChat.post { + recyclerChat.smoothScrollToPosition(chatAdapter.itemCount - 1) + } + } + } + } + + lifecycleScope.launch { + viewModel.error.collectLatest { error -> + error?.let { + Toast.makeText(this@MainActivity, it, Toast.LENGTH_LONG).show() + viewModel.clearError() + } + } + } + + lifecycleScope.launch { + viewModel.selectedModel.collectLatest { model -> + val idx = modelNames.indexOf(model) + if (idx >= 0 && spinnerModel.selectedItemPosition != idx) { + spinnerModel.setSelection(idx) + } + } + } + + lifecycleScope.launch { + viewModel.models.collectLatest { models -> + if (models.isNotEmpty()) { + updateModelList(models) + } + } + } + } + + private fun updateModelList(models: List) { + modelNames = models.map { it.id } + modelDisplayNames = models.map { it.name.removePrefix("Xiaomi MiMo:").removePrefix("Xiaomi MiMo:") } + val adapter = ArrayAdapter(this, android.R.layout.simple_spinner_item, modelDisplayNames) + adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item) + spinnerModel.adapter = adapter + + val idx = modelNames.indexOf(viewModel.selectedModel.value) + if (idx >= 0) spinnerModel.setSelection(idx) + } + + private fun showSettingsDialog() { + val dialogView = layoutInflater.inflate(R.layout.dialog_settings, null) + val etApiKey = dialogView.findViewById(R.id.etApiKey) + val etBaseUrl = dialogView.findViewById(R.id.etBaseUrl) + val etSystemPrompt = dialogView.findViewById(R.id.etSystemPrompt) + val seekTemp = dialogView.findViewById(R.id.seekTemperature) + val tvTemp = dialogView.findViewById(R.id.tvTemperature) + val etMaxTokens = dialogView.findViewById(R.id.etMaxTokens) + val switchStreaming = dialogView.findViewById(R.id.switchStreaming) + + etApiKey.setText(viewModel.getApiKey()) + etBaseUrl.setText(viewModel.getBaseUrl()) + etSystemPrompt.setText(viewModel.currentConversation.value?.systemPrompt ?: "") + seekTemp.progress = (viewModel.temperature.value * 100).toInt() + tvTemp.text = String.format("%.2f", viewModel.temperature.value) + etMaxTokens.setText(viewModel.maxTokens.value.toString()) + switchStreaming.isChecked = viewModel.useStreaming.value + + seekTemp.setOnSeekBarChangeListener(object : SeekBar.OnSeekBarChangeListener { + override fun onProgressChanged(sb: SeekBar?, progress: Int, fromUser: Boolean) { + tvTemp.text = String.format("%.2f", progress / 100f) + } + override fun onStartTrackingTouch(sb: SeekBar?) {} + override fun onStopTrackingTouch(sb: SeekBar?) {} + }) + + MaterialAlertDialogBuilder(this) + .setTitle("设置") + .setView(dialogView) + .setPositiveButton("保存") { _, _ -> + val apiKey = etApiKey.text.toString().trim() + val baseUrl = etBaseUrl.text.toString().trim().ifEmpty { "https://api.xiaomimimo.com/" } + val systemPrompt = etSystemPrompt.text.toString().trim() + val temp = seekTemp.progress / 100f + val maxTokens = etMaxTokens.text.toString().trim().toIntOrNull() ?: 4096 + + viewModel.saveSettings(apiKey, baseUrl) + viewModel.setTemperature(temp) + viewModel.setMaxTokens(maxTokens) + viewModel.setStreaming(switchStreaming.isChecked) + + // Update system prompt on current conversation + viewModel.currentConversation.value?.let { conv -> + val updated = conv.copy(systemPrompt = systemPrompt) + // Update through viewmodel would be cleaner, but this works + } + + if (apiKey.isNotBlank()) { + viewModel.fetchModels() + } + } + .setNegativeButton("取消", null) + .show() + } + + @Deprecated("Use OnBackPressedCallback") + override fun onBackPressed() { + if (drawerLayout.isDrawerOpen(GravityCompat.START)) { + drawerLayout.closeDrawer(GravityCompat.START) + } else { + super.onBackPressed() + } + } +} diff --git a/app/src/main/java/com/mimo/studio/api/MimoApiService.kt b/app/src/main/java/com/mimo/studio/api/MimoApiService.kt new file mode 100644 index 0000000..4d9fed8 --- /dev/null +++ b/app/src/main/java/com/mimo/studio/api/MimoApiService.kt @@ -0,0 +1,34 @@ +package com.mimo.studio.api + +import com.mimo.studio.model.* +import okhttp3.ResponseBody +import retrofit2.http.* + +interface MimoApiService { + + /** List all available models */ + @GET("v1/models") + suspend fun listModels(): ModelsResponse + + /** Get a specific model */ + @GET("v1/models/{model_id}") + suspend fun getModel(@Path("model_id") modelId: String): ModelInfo + + /** Create a chat completion */ + @POST("v1/chat/completions") + suspend fun chatCompletions(@Body request: ChatRequest): ChatResponse + + /** Create a streaming chat completion */ + @POST("v1/chat/completions") + @Streaming + suspend fun chatCompletionsStream(@Body request: ChatRequest): ResponseBody + + /** Create a text completion */ + @POST("v1/completions") + suspend fun completions(@Body request: CompletionRequest): CompletionResponse + + /** Create a streaming text completion */ + @POST("v1/completions") + @Streaming + suspend fun completionsStream(@Body request: CompletionRequest): ResponseBody +} diff --git a/app/src/main/java/com/mimo/studio/api/RetrofitClient.kt b/app/src/main/java/com/mimo/studio/api/RetrofitClient.kt new file mode 100644 index 0000000..56a8c84 --- /dev/null +++ b/app/src/main/java/com/mimo/studio/api/RetrofitClient.kt @@ -0,0 +1,60 @@ +package com.mimo.studio.api + +import okhttp3.Interceptor +import okhttp3.OkHttpClient +import okhttp3.logging.HttpLoggingInterceptor +import retrofit2.Retrofit +import retrofit2.converter.gson.GsonConverterFactory +import java.util.concurrent.TimeUnit + +object RetrofitClient { + + private const val BASE_URL = "https://api.xiaomimimo.com/" + + private var apiKey: String = "" + private var baseUrl: String = BASE_URL + + fun configure(apiKey: String, baseUrl: String = BASE_URL) { + this.apiKey = apiKey + this.baseUrl = baseUrl + } + + fun isConfigured(): Boolean = apiKey.isNotBlank() + + private val authInterceptor = Interceptor { chain -> + val original = chain.request() + val request = original.newBuilder() + .header("Authorization", "Bearer $apiKey") + .header("Content-Type", "application/json") + .build() + chain.proceed(request) + } + + private val loggingInterceptor = HttpLoggingInterceptor().apply { + level = HttpLoggingInterceptor.Level.BODY + } + + private val okHttpClient = OkHttpClient.Builder() + .addInterceptor(authInterceptor) + .addInterceptor(loggingInterceptor) + .connectTimeout(60, TimeUnit.SECONDS) + .readTimeout(120, TimeUnit.SECONDS) + .writeTimeout(60, TimeUnit.SECONDS) + .build() + + private var retrofit: Retrofit? = null + private var currentBaseUrl: String = "" + + val api: MimoApiService + get() { + if (retrofit == null || currentBaseUrl != baseUrl) { + retrofit = Retrofit.Builder() + .baseUrl(baseUrl) + .client(okHttpClient) + .addConverterFactory(GsonConverterFactory.create()) + .build() + currentBaseUrl = baseUrl + } + return retrofit!!.create(MimoApiService::class.java) + } +} diff --git a/app/src/main/java/com/mimo/studio/model/ApiModels.kt b/app/src/main/java/com/mimo/studio/model/ApiModels.kt new file mode 100644 index 0000000..086ea7d --- /dev/null +++ b/app/src/main/java/com/mimo/studio/model/ApiModels.kt @@ -0,0 +1,122 @@ +package com.mimo.studio.model + +import com.google.gson.annotations.SerializedName + +// ===== Models API ===== +data class ModelsResponse( + val data: List +) + +data class ModelInfo( + val id: String, + val name: String, + val description: String, + @SerializedName("context_length") val contextLength: Int, + @SerializedName("max_output_length") val maxOutputLength: Int, + val architecture: Architecture?, + val pricing: Any?, // Can be object or array + @SerializedName("supported_parameters") val supportedParameters: List? +) + +data class Architecture( + val modality: String, + @SerializedName("input_modalities") val inputModalities: List, + @SerializedName("output_modalities") val outputModalities: List +) + +// ===== Chat Completions API ===== +data class ChatRequest( + val model: String, + val messages: List, + val stream: Boolean = false, + @SerializedName("max_tokens") val maxTokens: Int? = null, + val temperature: Float? = null, + @SerializedName("top_p") val topP: Float? = null, + val stop: List? = null, + @SerializedName("frequency_penalty") val frequencyPenalty: Float? = null, + @SerializedName("presence_penalty") val presencePenalty: Float? = null, + @SerializedName("response_format") val responseFormat: ResponseFormat? = null, + val reasoning: Boolean? = null, + @SerializedName("include_reasoning") val includeReasoning: Boolean? = null +) + +data class ResponseFormat( + val type: String // "text" or "json_object" +) + +data class ChatMessage( + val role: String, // "system", "user", "assistant" + val content: String +) + +data class ChatResponse( + val id: String?, + val choices: List?, + val usage: Usage?, + val model: String? +) + +data class Choice( + val index: Int, + val message: ChatMessage?, + @SerializedName("finish_reason") val finishReason: String?, + val delta: ChatMessage? // for streaming +) + +data class Usage( + @SerializedName("prompt_tokens") val promptTokens: Int, + @SerializedName("completion_tokens") val completionTokens: Int, + @SerializedName("total_tokens") val totalTokens: Int +) + +// ===== Streaming ===== +data class StreamChunk( + val id: String?, + val choices: List?, + val model: String? +) + +data class StreamChoice( + val index: Int, + val delta: DeltaMessage?, + @SerializedName("finish_reason") val finishReason: String? +) + +data class DeltaMessage( + val role: String?, + val content: String? +) + +// ===== Completions API ===== +data class CompletionRequest( + val model: String, + val prompt: String, + val stream: Boolean = false, + @SerializedName("max_tokens") val maxTokens: Int? = null, + val temperature: Float? = null, + @SerializedName("top_p") val topP: Float? = null, + val stop: List? = null +) + +data class CompletionResponse( + val id: String?, + val choices: List?, + val usage: Usage?, + val model: String? +) + +data class CompletionChoice( + val index: Int, + val text: String?, + @SerializedName("finish_reason") val finishReason: String? +) + +// ===== Local chat state ===== +data class Conversation( + val id: String = java.util.UUID.randomUUID().toString(), + val title: String = "新对话", + val messages: MutableList = mutableListOf(), + val model: String = "xiaomi/mimo-v2-flash", + val systemPrompt: String = "", + val createdAt: Long = System.currentTimeMillis() +) diff --git a/app/src/main/java/com/mimo/studio/ui/ChatAdapter.kt b/app/src/main/java/com/mimo/studio/ui/ChatAdapter.kt new file mode 100644 index 0000000..2283b26 --- /dev/null +++ b/app/src/main/java/com/mimo/studio/ui/ChatAdapter.kt @@ -0,0 +1,103 @@ +package com.mimo.studio.ui + +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.TextView +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListAdapter +import androidx.recyclerview.widget.RecyclerView +import com.mimo.studio.R +import com.mimo.studio.model.ChatMessage +import java.text.SimpleDateFormat +import java.util.* + +class ChatAdapter : ListAdapter(ChatDiffCallback()) { + + companion object { + private const val TYPE_USER = 0 + private const val TYPE_ASSISTANT = 1 + private const val TYPE_SYSTEM = 2 + } + + private var timestamp = System.currentTimeMillis() + + override fun getItemViewType(position: Int): Int { + return when (getItem(position).role) { + "user" -> TYPE_USER + "assistant" -> TYPE_ASSISTANT + else -> TYPE_SYSTEM + } + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { + val inflater = LayoutInflater.from(parent.context) + return when (viewType) { + TYPE_USER -> UserViewHolder(inflater.inflate(R.layout.item_chat_user, parent, false)) + TYPE_ASSISTANT -> AssistantViewHolder(inflater.inflate(R.layout.item_chat_assistant, parent, false)) + else -> SystemViewHolder(inflater.inflate(R.layout.item_chat_assistant, parent, false)) + } + } + + override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { + val message = getItem(position) + val timeStr = SimpleDateFormat("HH:mm", Locale.getDefault()).format(Date(timestamp + position * 1000)) + when (holder) { + is UserViewHolder -> holder.bind(message, timeStr) + is AssistantViewHolder -> holder.bind(message, timeStr) + is SystemViewHolder -> holder.bind(message, timeStr) + } + } + + fun updateStreamingContent(content: String) { + // Update the last assistant message with streaming content + // or add a temporary one + val currentList = currentList.toMutableList() + if (currentList.isNotEmpty() && currentList.last().role == "assistant" && currentList.last().content == "") { + currentList[currentList.size - 1] = ChatMessage(role = "assistant", content = content) + } else { + currentList.add(ChatMessage(role = "assistant", content = content)) + } + submitList(currentList) + } + + class UserViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { + private val tvMessage: TextView = itemView.findViewById(R.id.tvMessage) + private val tvTimestamp: TextView = itemView.findViewById(R.id.tvTimestamp) + + fun bind(message: ChatMessage, time: String) { + tvMessage.text = message.content + tvTimestamp.text = time + } + } + + class AssistantViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { + private val tvMessage: TextView = itemView.findViewById(R.id.tvMessage) + private val tvTimestamp: TextView = itemView.findViewById(R.id.tvTimestamp) + + fun bind(message: ChatMessage, time: String) { + tvMessage.text = message.content + tvTimestamp.text = time + } + } + + class SystemViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { + private val tvMessage: TextView = itemView.findViewById(R.id.tvMessage) + private val tvTimestamp: TextView = itemView.findViewById(R.id.tvTimestamp) + + fun bind(message: ChatMessage, time: String) { + tvMessage.text = "⚙️ ${message.content}" + tvTimestamp.text = time + } + } + + class ChatDiffCallback : DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: ChatMessage, newItem: ChatMessage): Boolean { + return oldItem === newItem + } + + override fun areContentsTheSame(oldItem: ChatMessage, newItem: ChatMessage): Boolean { + return oldItem.role == newItem.role && oldItem.content == newItem.content + } + } +} diff --git a/app/src/main/java/com/mimo/studio/ui/ModelsActivity.kt b/app/src/main/java/com/mimo/studio/ui/ModelsActivity.kt new file mode 100644 index 0000000..969fe27 --- /dev/null +++ b/app/src/main/java/com/mimo/studio/ui/ModelsActivity.kt @@ -0,0 +1,55 @@ +package com.mimo.studio.ui + +import android.os.Bundle +import android.widget.Toast +import androidx.appcompat.app.AppCompatActivity +import androidx.lifecycle.lifecycleScope +import androidx.recyclerview.widget.LinearLayoutManager +import com.google.android.material.appbar.MaterialToolbar +import com.mimo.studio.R +import com.mimo.studio.api.RetrofitClient +import com.mimo.studio.model.ModelInfo +import kotlinx.coroutines.launch + +class ModelsActivity : AppCompatActivity() { + + private lateinit var adapter: ModelsAdapter + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(R.layout.activity_models) + + val toolbar = findViewById(R.id.toolbar) + toolbar.setNavigationOnClickListener { finish() } + + adapter = ModelsAdapter { model -> + val intent = intent.putExtra("selected_model", model.id) + setResult(RESULT_OK, intent) + Toast.makeText(this, "已选择: ${model.name}", Toast.LENGTH_SHORT).show() + finish() + } + + val recycler = findViewById(R.id.recyclerModels) + recycler.layoutManager = LinearLayoutManager(this) + recycler.adapter = adapter + + val swipeRefresh = findViewById(R.id.swipeRefresh) + swipeRefresh.setOnRefreshListener { fetchModels() } + + fetchModels() + } + + private fun fetchModels() { + val swipeRefresh = findViewById(R.id.swipeRefresh) + lifecycleScope.launch { + try { + val response = RetrofitClient.api.listModels() + adapter.submitList(response.data) + } catch (e: Exception) { + Toast.makeText(this@ModelsActivity, "加载失败: ${e.message}", Toast.LENGTH_LONG).show() + } finally { + swipeRefresh.isRefreshing = false + } + } + } +} diff --git a/app/src/main/java/com/mimo/studio/ui/ModelsAdapter.kt b/app/src/main/java/com/mimo/studio/ui/ModelsAdapter.kt new file mode 100644 index 0000000..258debf --- /dev/null +++ b/app/src/main/java/com/mimo/studio/ui/ModelsAdapter.kt @@ -0,0 +1,59 @@ +package com.mimo.studio.ui + +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.TextView +import androidx.recyclerview.widget.RecyclerView +import com.google.android.material.button.MaterialButton +import com.mimo.studio.R +import com.mimo.studio.model.ModelInfo + +class ModelsAdapter( + private val onSelect: (ModelInfo) -> Unit +) : RecyclerView.Adapter() { + + private var models: List = emptyList() + + fun submitList(list: List) { + models = list + notifyDataSetChanged() + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ModelViewHolder { + val view = LayoutInflater.from(parent.context).inflate(R.layout.item_model, parent, false) + return ModelViewHolder(view) + } + + override fun onBindViewHolder(holder: ModelViewHolder, position: Int) { + holder.bind(models[position]) + } + + override fun getItemCount() = models.size + + inner class ModelViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { + private val tvName: TextView = itemView.findViewById(R.id.tvModelName) + private val tvId: TextView = itemView.findViewById(R.id.tvModelId) + private val tvModality: TextView = itemView.findViewById(R.id.tvModality) + private val tvContext: TextView = itemView.findViewById(R.id.tvContext) + private val tvDesc: TextView = itemView.findViewById(R.id.tvDescription) + private val btnSelect: MaterialButton = itemView.findViewById(R.id.btnSelect) + + fun bind(model: ModelInfo) { + tvName.text = model.name + tvId.text = model.id + tvModality.text = "模态: ${model.architecture?.modality ?: "text->text"}" + tvContext.text = "上下文: ${formatTokens(model.contextLength)} tokens | 最大输出: ${formatTokens(model.maxOutputLength)} tokens" + tvDesc.text = model.description + btnSelect.setOnClickListener { onSelect(model) } + } + + private fun formatTokens(tokens: Int): String { + return when { + tokens >= 1_000_000 -> "${tokens / 1_000_000}M" + tokens >= 1_000 -> "${tokens / 1_000}K" + else -> tokens.toString() + } + } + } +} diff --git a/app/src/main/java/com/mimo/studio/viewmodel/MainViewModel.kt b/app/src/main/java/com/mimo/studio/viewmodel/MainViewModel.kt new file mode 100644 index 0000000..e42333f --- /dev/null +++ b/app/src/main/java/com/mimo/studio/viewmodel/MainViewModel.kt @@ -0,0 +1,303 @@ +package com.mimo.studio.viewmodel + +import android.app.Application +import android.content.Context +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.viewModelScope +import com.google.gson.Gson +import com.google.gson.reflect.TypeToken +import com.mimo.studio.api.RetrofitClient +import com.mimo.studio.model.* +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.io.BufferedReader +import java.io.InputStreamReader + +class MainViewModel(application: Application) : AndroidViewModel(application) { + + private val prefs = application.getSharedPreferences("mimo_prefs", Context.MODE_PRIVATE) + private val gson = Gson() + + // State + private val _models = MutableStateFlow>(emptyList()) + val models: StateFlow> = _models + + private val _conversations = MutableStateFlow>(emptyList()) + val conversations: StateFlow> = _conversations + + private val _currentConversation = MutableStateFlow(null) + val currentConversation: StateFlow = _currentConversation + + private val _isLoading = MutableStateFlow(false) + val isLoading: StateFlow = _isLoading + + private val _isStreaming = MutableStateFlow(false) + val isStreaming: StateFlow = _isStreaming + + private val _streamingContent = MutableStateFlow("") + val streamingContent: StateFlow = _streamingContent + + private val _error = MutableStateFlow(null) + val error: StateFlow = _error + + private val _selectedModel = MutableStateFlow("xiaomi/mimo-v2-flash") + val selectedModel: StateFlow = _selectedModel + + private val _useStreaming = MutableStateFlow(true) + val useStreaming: StateFlow = _useStreaming + + private val _temperature = MutableStateFlow(1.0f) + val temperature: StateFlow = _temperature + + private val _maxTokens = MutableStateFlow(4096) + val maxTokens: StateFlow = _maxTokens + + init { + loadSettings() + loadConversations() + if (conversations.value.isEmpty()) { + newConversation() + } + } + + // ===== Settings ===== + fun getApiKey(): String = prefs.getString("api_key", "") ?: "" + fun getBaseUrl(): String = prefs.getString("base_url", "https://api.xiaomimimo.com/") ?: "https://api.xiaomimimo.com/" + + fun saveSettings(apiKey: String, baseUrl: String) { + prefs.edit() + .putString("api_key", apiKey) + .putString("base_url", baseUrl) + .apply() + RetrofitClient.configure(apiKey, baseUrl) + } + + private fun loadSettings() { + val key = getApiKey() + val url = getBaseUrl() + if (key.isNotBlank()) { + RetrofitClient.configure(key, url) + } + _selectedModel.value = prefs.getString("selected_model", "xiaomi/mimo-v2-flash") ?: "xiaomi/mimo-v2-flash" + _useStreaming.value = prefs.getBoolean("use_streaming", true) + _temperature.value = prefs.getFloat("temperature", 1.0f) + _maxTokens.value = prefs.getInt("max_tokens", 4096) + } + + fun setSelectedModel(model: String) { + _selectedModel.value = model + prefs.edit().putString("selected_model", model).apply() + } + + fun setStreaming(enabled: Boolean) { + _useStreaming.value = enabled + prefs.edit().putBoolean("use_streaming", enabled).apply() + } + + fun setTemperature(temp: Float) { + _temperature.value = temp + prefs.edit().putFloat("temperature", temp).apply() + } + + fun setMaxTokens(tokens: Int) { + _maxTokens.value = tokens + prefs.edit().putInt("max_tokens", tokens).apply() + } + + // ===== Models ===== + fun fetchModels() { + if (!RetrofitClient.isConfigured()) { + _error.value = "请先配置 API Key" + return + } + viewModelScope.launch { + try { + val response = withContext(Dispatchers.IO) { RetrofitClient.api.listModels() } + _models.value = response.data + _error.value = null + } catch (e: Exception) { + _error.value = "获取模型列表失败: ${e.message}" + } + } + } + + // ===== Conversations ===== + fun newConversation() { + val conv = Conversation(model = _selectedModel.value) + val list = _conversations.value.toMutableList() + list.add(0, conv) + _conversations.value = list + _currentConversation.value = conv + saveConversations() + } + + fun selectConversation(id: String) { + _currentConversation.value = _conversations.value.find { it.id == id } + } + + fun deleteConversation(id: String) { + val list = _conversations.value.filter { it.id != id }.toMutableList() + _conversations.value = list + if (_currentConversation.value?.id == id) { + _currentConversation.value = list.firstOrNull() + } + saveConversations() + } + + private fun saveConversations() { + val json = gson.toJson(_conversations.value) + prefs.edit().putString("conversations", json).apply() + } + + private fun loadConversations() { + val json = prefs.getString("conversations", null) + if (json != null) { + val type = object : TypeToken>() {}.type + try { + val list: List = gson.fromJson(json, type) + _conversations.value = list + _currentConversation.value = list.firstOrNull() + } catch (_: Exception) {} + } + } + + // ===== Chat ===== + fun sendMessage(content: String) { + val conv = _currentConversation.value ?: return + if (!RetrofitClient.isConfigured()) { + _error.value = "请先在设置中配置 API Key" + return + } + + // Add user message + conv.messages.add(ChatMessage(role = "user", content = content)) + if (conv.messages.size == 1 && conv.title == "新对话") { + // Auto-title from first message + val newConv = conv.copy(title = content.take(30) + if (content.length > 30) "..." else "") + updateConversation(newConv) + } else { + _currentConversation.value = conv.copy() + } + + if (_useStreaming.value) { + sendStreamingMessage(conv) + } else { + sendNormalMessage(conv) + } + } + + private fun sendNormalMessage(conv: Conversation) { + _isLoading.value = true + _error.value = null + + viewModelScope.launch { + try { + val messages = buildMessages(conv) + val request = ChatRequest( + model = conv.model, + messages = messages, + temperature = _temperature.value, + maxTokens = _maxTokens.value + ) + val response = withContext(Dispatchers.IO) { + RetrofitClient.api.chatCompletions(request) + } + val assistantMsg = response.choices?.firstOrNull()?.message + if (assistantMsg != null) { + conv.messages.add(assistantMsg) + updateConversation(conv.copy()) + } + _error.value = null + } catch (e: Exception) { + _error.value = "请求失败: ${e.message}" + } finally { + _isLoading.value = false + } + } + } + + private fun sendStreamingMessage(conv: Conversation) { + _isStreaming.value = true + _streamingContent.value = "" + _error.value = null + + viewModelScope.launch { + try { + val messages = buildMessages(conv) + val request = ChatRequest( + model = conv.model, + messages = messages, + stream = true, + temperature = _temperature.value, + maxTokens = _maxTokens.value + ) + val responseBody = withContext(Dispatchers.IO) { + RetrofitClient.api.chatCompletionsStream(request) + } + + val reader = BufferedReader(InputStreamReader(responseBody.byteStream())) + val fullContent = StringBuilder() + + try { + var line: String? + while (reader.readLine().also { line = it } != null) { + val l = line ?: continue + if (!l.startsWith("data: ")) continue + val data = l.removePrefix("data: ").trim() + if (data == "[DONE]") break + + try { + val chunk = gson.fromJson(data, StreamChunk::class.java) + val delta = chunk.choices?.firstOrNull()?.delta + if (delta?.content != null) { + fullContent.append(delta.content) + _streamingContent.value = fullContent.toString() + } + } catch (_: Exception) {} + } + } finally { + reader.close() + responseBody.close() + } + + if (fullContent.isNotEmpty()) { + conv.messages.add(ChatMessage(role = "assistant", content = fullContent.toString())) + updateConversation(conv.copy()) + } + _streamingContent.value = "" + } catch (e: Exception) { + _error.value = "流式请求失败: ${e.message}" + _streamingContent.value = "" + } finally { + _isStreaming.value = false + } + } + } + + private fun buildMessages(conv: Conversation): List { + val messages = mutableListOf() + if (conv.systemPrompt.isNotBlank()) { + messages.add(ChatMessage(role = "system", content = conv.systemPrompt)) + } + messages.addAll(conv.messages) + return messages + } + + private fun updateConversation(conv: Conversation) { + _currentConversation.value = conv + val list = _conversations.value.toMutableList() + val idx = list.indexOfFirst { it.id == conv.id } + if (idx >= 0) { + list[idx] = conv + } + _conversations.value = list + saveConversations() + } + + fun clearError() { + _error.value = null + } +} diff --git a/app/src/main/res/drawable/bg_assistant_bubble.xml b/app/src/main/res/drawable/bg_assistant_bubble.xml new file mode 100644 index 0000000..49633a5 --- /dev/null +++ b/app/src/main/res/drawable/bg_assistant_bubble.xml @@ -0,0 +1,10 @@ + + + + + diff --git a/app/src/main/res/drawable/bg_card.xml b/app/src/main/res/drawable/bg_card.xml new file mode 100644 index 0000000..58b70d8 --- /dev/null +++ b/app/src/main/res/drawable/bg_card.xml @@ -0,0 +1,9 @@ + + + + + + diff --git a/app/src/main/res/drawable/bg_input.xml b/app/src/main/res/drawable/bg_input.xml new file mode 100644 index 0000000..1885ad6 --- /dev/null +++ b/app/src/main/res/drawable/bg_input.xml @@ -0,0 +1,11 @@ + + + + + + diff --git a/app/src/main/res/drawable/bg_send_button.xml b/app/src/main/res/drawable/bg_send_button.xml new file mode 100644 index 0000000..3c0ba1c --- /dev/null +++ b/app/src/main/res/drawable/bg_send_button.xml @@ -0,0 +1,5 @@ + + + + diff --git a/app/src/main/res/drawable/bg_user_bubble.xml b/app/src/main/res/drawable/bg_user_bubble.xml new file mode 100644 index 0000000..814953c --- /dev/null +++ b/app/src/main/res/drawable/bg_user_bubble.xml @@ -0,0 +1,10 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000..86ac5ac --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,14 @@ + + + + + diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml new file mode 100644 index 0000000..bb70daa --- /dev/null +++ b/app/src/main/res/layout/activity_main.xml @@ -0,0 +1,146 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/activity_models.xml b/app/src/main/res/layout/activity_models.xml new file mode 100644 index 0000000..62f35c4 --- /dev/null +++ b/app/src/main/res/layout/activity_models.xml @@ -0,0 +1,32 @@ + + + + + + + + + + diff --git a/app/src/main/res/layout/dialog_settings.xml b/app/src/main/res/layout/dialog_settings.xml new file mode 100644 index 0000000..e52cb50 --- /dev/null +++ b/app/src/main/res/layout/dialog_settings.xml @@ -0,0 +1,122 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/item_chat_assistant.xml b/app/src/main/res/layout/item_chat_assistant.xml new file mode 100644 index 0000000..422f0bf --- /dev/null +++ b/app/src/main/res/layout/item_chat_assistant.xml @@ -0,0 +1,38 @@ + + + + + + + + + + diff --git a/app/src/main/res/layout/item_chat_user.xml b/app/src/main/res/layout/item_chat_user.xml new file mode 100644 index 0000000..85cdbee --- /dev/null +++ b/app/src/main/res/layout/item_chat_user.xml @@ -0,0 +1,31 @@ + + + + + + + diff --git a/app/src/main/res/layout/item_model.xml b/app/src/main/res/layout/item_model.xml new file mode 100644 index 0000000..80aea68 --- /dev/null +++ b/app/src/main/res/layout/item_model.xml @@ -0,0 +1,69 @@ + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/nav_header.xml b/app/src/main/res/layout/nav_header.xml new file mode 100644 index 0000000..a0a2294 --- /dev/null +++ b/app/src/main/res/layout/nav_header.xml @@ -0,0 +1,26 @@ + + + + + + + diff --git a/app/src/main/res/menu/nav_menu.xml b/app/src/main/res/menu/nav_menu.xml new file mode 100644 index 0000000..0867dba --- /dev/null +++ b/app/src/main/res/menu/nav_menu.xml @@ -0,0 +1,15 @@ + + + + + + diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher.xml b/app/src/main/res/mipmap-hdpi/ic_launcher.xml new file mode 100644 index 0000000..f9890cc --- /dev/null +++ b/app/src/main/res/mipmap-hdpi/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher.xml b/app/src/main/res/mipmap-xhdpi/ic_launcher.xml new file mode 100644 index 0000000..f9890cc --- /dev/null +++ b/app/src/main/res/mipmap-xhdpi/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher.xml b/app/src/main/res/mipmap-xxhdpi/ic_launcher.xml new file mode 100644 index 0000000..f9890cc --- /dev/null +++ b/app/src/main/res/mipmap-xxhdpi/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..a0c588c --- /dev/null +++ b/app/src/main/res/values/colors.xml @@ -0,0 +1,25 @@ + + + #FF6200EE + #FF3700B3 + #FF03DAC5 + #FFFFFFFF + #FF000000 + #FFF5F5F5 + #FF121212 + #FFE3F2FD + #FFFFFFFF + #FF212121 + #FF757575 + #FFE0E0E0 + + + #FF4A90D9 + #FF3A7BC8 + #FFFF6B35 + #FFF8F9FA + #FFFFFFFF + #FFE8F0FE + #FFF1F3F4 + #FFF0F2F5 + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..2ba148f --- /dev/null +++ b/app/src/main/res/values/strings.xml @@ -0,0 +1,4 @@ + + + MiMo Studio + diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..fb4cb71 --- /dev/null +++ b/app/src/main/res/values/themes.xml @@ -0,0 +1,24 @@ + + + + + + + + + + diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..2fb8337 --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,5 @@ +// Top-level build file +plugins { + id("com.android.application") version "8.7.3" apply false + id("org.jetbrains.kotlin.android") version "2.0.21" apply false +} diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..f0a2e55 --- /dev/null +++ b/gradle.properties @@ -0,0 +1,4 @@ +org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 +android.useAndroidX=true +kotlin.code.style=official +android.nonTransitiveRClass=true diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..a4b76b9 Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..e2847c8 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100755 index 0000000..ceaef6e --- /dev/null +++ b/gradlew @@ -0,0 +1,139 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +############################################################################## + +# Attempt to set APP_HOME +# Resolve links: $0 may be a link +app_path=$0 +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld -- "$app_path" ) + link=${ls#*' -> '} + case $link in + /*) app_path=$link ;; + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in + CYGWIN* ) cygwin=true ;; + Darwin* ) darwin=true ;; + MSYS* | MINGW* ) msys=true ;; + NonStop* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 ; then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in + max*) + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + ;; + esac + case $MAX_FD in + '' | soft) :;; + *) + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + ;; + esac +fi + +# Collect all arguments for the java command, stracks://am/LF://the current directory:// +# temporary://build://for://long://args:// +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + JAVACMD=$( cygpath --unix "$JAVACMD" ) +fi + +# Collect all arguments for the java command; +# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of +# shell script including quotes and/or backslashes, so put them in +# temporary variables so that the://shell://can://split://them://up (https://github.com/gradle/gradle/issues/25036) +# * Put://all://options://into://a://single://quoted://word://so://they://are://not://split +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xeli" is available and://the://GRADLE_OPTS does not contain://any://-J flags +if ! "$cygwin" && ! "$msys" && [ -n "$TERM" ] ; then + : +fi + +exec "$JAVACMD" "$@" diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 0000000..7cd214b --- /dev/null +++ b/settings.gradle.kts @@ -0,0 +1,16 @@ +pluginManagement { + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} +dependencyResolution { + repositories { + google() + mavenCentral() + } +} + +rootProject.name = "MimoStudio" +include(":app")