Unverified Commit da51aa91 authored by divyanshu bhargava's avatar divyanshu bhargava Committed by GitHub

Merge branch 'develop' into android-draw

parents 0235ef94 79dc4dbf
......@@ -20,8 +20,8 @@
android:fullBackupContent="@xml/backup_config"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:networkSecurityConfig="@xml/network_security_config"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true">
<activity
......@@ -30,17 +30,18 @@
android:screenOrientation="portrait"
android:theme="@style/AuthenticationTheme"
android:windowSoftInputMode="adjustResize">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.BROWSABLE" />
<category android:name="android.intent.category.DEFAULT" />
<data
android:host="auth"
android:scheme="rocketchat" />
......@@ -66,7 +67,7 @@
android:windowSoftInputMode="adjustResize|stateAlwaysHidden" />
<activity
android:name=".webview.cas.ui.CasWebViewActivity"
android:name=".webview.sso.ui.SsoWebViewActivity"
android:theme="@style/AppTheme"
android:windowSoftInputMode="adjustResize|stateAlwaysHidden" />
......@@ -80,12 +81,12 @@
android:theme="@style/AppTheme"
android:windowSoftInputMode="adjustResize|stateAlwaysHidden" />
<!-- TODO: Change to fragment-->
<!-- TODO: Change to fragment -->
<activity
android:name=".settings.password.ui.PasswordActivity"
android:theme="@style/AppTheme" />
<!-- TODO: Change to fragment-->
<!-- TODO: Change to fragment -->
<activity
android:name=".settings.about.ui.AboutActivity"
android:theme="@style/AppTheme" />
......@@ -118,6 +119,7 @@
<action android:name="com.google.firebase.INSTANCE_ID_EVENT" />
</intent-filter>
</service>
<service
android:name=".push.GcmListenerService"
android:exported="false">
......@@ -125,6 +127,7 @@
<action android:name="com.google.android.c2dm.intent.RECEIVE" />
</intent-filter>
</service>
<service
android:name=".chatroom.service.MessageService"
android:exported="true"
......@@ -135,4 +138,4 @@
android:value="12ac6e94f850aaffcdff52001af77ca415d06a43" />
</application>
</manifest>
</manifest>
\ No newline at end of file
......@@ -25,8 +25,9 @@ import javax.inject.Inject
private const val TYPE_LOGIN_USER_EMAIL = 0
private const val TYPE_LOGIN_CAS = 1
private const val TYPE_LOGIN_OAUTH = 2
private const val TYPE_LOGIN_DEEP_LINK = 3
private const val TYPE_LOGIN_SAML = 2
private const val TYPE_LOGIN_OAUTH = 3
private const val TYPE_LOGIN_DEEP_LINK = 4
private const val SERVICE_NAME_FACEBOOK = "facebook"
private const val SERVICE_NAME_GITHUB = "github"
private const val SERVICE_NAME_GOOGLE = "google"
......@@ -82,14 +83,19 @@ class LoginPresenter @Inject constructor(
}
}
fun authenticateWithCas(token: String) {
credentialToken = token
fun authenticateWithCas(casToken: String) {
credentialToken = casToken
doAuthentication(TYPE_LOGIN_CAS)
}
fun authenticateWithOauth(token: String, secret: String) {
credentialToken = token
credentialSecret = secret
fun authenticateWithSaml(samlToken: String) {
credentialToken = samlToken
doAuthentication(TYPE_LOGIN_SAML)
}
fun authenticateWithOauth(oauthToken: String, oauthSecret: String) {
credentialToken = oauthToken
credentialSecret = oauthSecret
doAuthentication(TYPE_LOGIN_OAUTH)
}
......@@ -99,7 +105,6 @@ class LoginPresenter @Inject constructor(
deepLinkUserId = deepLinkInfo.userId
deepLinkToken = deepLinkInfo.token
tokenRepository.save(serverUrl, Token(deepLinkUserId, deepLinkToken))
doAuthentication(TYPE_LOGIN_DEEP_LINK)
}
......@@ -124,8 +129,11 @@ class LoginPresenter @Inject constructor(
private fun setupCasView() {
if (settings.isCasAuthenticationEnabled()) {
val token = generateRandomString(17)
view.setupCasButtonListener(settings.casLoginUrl().casUrl(currentServer, token), token)
val casToken = generateRandomString(17)
view.setupCasButtonListener(
settings.casLoginUrl().casUrl(currentServer, casToken),
casToken
)
view.showCasButton()
}
}
......@@ -216,7 +224,7 @@ class LoginPresenter @Inject constructor(
// totalSocialAccountsEnabled++
}
if (settings.isTwitterAuthenticationEnabled()) {
//TODO: Remove until we have this implemented
//TODO: Remove until Twitter provides support to OAuth2
// view.enableLoginByTwitter()
// totalSocialAccountsEnabled++
}
......@@ -261,8 +269,23 @@ class LoginPresenter @Inject constructor(
customOauthUrl,
state,
serviceName,
getCustomOauthServiceNameColor(service),
getCustomOauthButtonColor(service)
getServiceNameColor(service),
getServiceButtonColor(service)
)
totalSocialAccountsEnabled++
}
}
getSamlServices(services).let {
val samlToken = generateRandomString(17)
for (service in it) {
view.addSamlServiceButton(
currentServer.samlUrl(getSamlProvider(service), samlToken),
samlToken,
getSamlServiceName(service),
getServiceNameColor(service),
getServiceButtonColor(service)
)
totalSocialAccountsEnabled++
}
......@@ -307,6 +330,10 @@ class LoginPresenter @Inject constructor(
delay(3, TimeUnit.SECONDS)
client.loginWithCas(credentialToken)
}
TYPE_LOGIN_SAML -> {
delay(3, TimeUnit.SECONDS)
client.loginWithSaml(credentialToken)
}
TYPE_LOGIN_OAUTH -> {
client.loginWithOauth(credentialToken, credentialSecret)
}
......@@ -319,7 +346,7 @@ class LoginPresenter @Inject constructor(
}
}
else -> {
throw IllegalStateException("Expected TYPE_LOGIN_USER_EMAIL, TYPE_LOGIN_CAS, TYPE_LOGIN_OAUTH or TYPE_LOGIN_DEEP_LINK")
throw IllegalStateException("Expected TYPE_LOGIN_USER_EMAIL, TYPE_LOGIN_CAS,TYPE_LOGIN_SAML, TYPE_LOGIN_OAUTH or TYPE_LOGIN_DEEP_LINK")
}
}
}
......@@ -365,6 +392,18 @@ class LoginPresenter @Inject constructor(
}.toString()
}
private fun getSamlServices(listMap: List<Map<String, Any>>): List<Map<String, Any>> {
return listMap.filter { map -> map["service"] == "saml" }
}
private fun getSamlServiceName(service: Map<String, Any>): String {
return service["buttonLabelText"].toString()
}
private fun getSamlProvider(service: Map<String, Any>): String {
return (service["clientConfig"] as Map<*, *>)["provider"].toString()
}
private fun getCustomOauthServices(listMap: List<Map<String, Any>>): List<Map<String, Any>> {
return listMap.filter { map -> map["custom"] == true }
}
......@@ -389,11 +428,11 @@ class LoginPresenter @Inject constructor(
return service["scope"].toString()
}
private fun getCustomOauthButtonColor(service: Map<String, Any>): Int {
private fun getServiceButtonColor(service: Map<String, Any>): Int {
return service["buttonColor"].toString().parseColor()
}
private fun getCustomOauthServiceNameColor(service: Map<String, Any>): Int {
private fun getServiceNameColor(service: Map<String, Any>): Int {
return service["buttonLabelColor"].toString().parseColor()
}
......
......@@ -87,7 +87,7 @@ interface LoginView : LoadingView, MessageView {
* Enables and shows the oauth view if there is login via social accounts enabled by the server settings.
*
* REMARK: We must show at maximum *three* social accounts views ([enableLoginByFacebook], [enableLoginByGithub], [enableLoginByGoogle],
* [enableLoginByLinkedin], [enableLoginByMeteor], [enableLoginByTwitter], [enableLoginByGitlab] or [addCustomOauthServiceButton]) for the oauth view.
* [enableLoginByLinkedin], [enableLoginByMeteor], [enableLoginByTwitter], [enableLoginByGitlab], [addCustomOauthServiceButton] or [addSamlServiceButton]) for the oauth view.
* If the possibility of login via social accounts exceeds 3 different ways we should set up the FAB ([setupFabListener]) to show the remaining view(s).
*/
fun enableOauthView()
......@@ -197,7 +197,7 @@ interface LoginView : LoadingView, MessageView {
* @state A random string generated by the app, which you'll verify later (to protect against forgery attacks).
* @serviceName The custom OAuth service name.
* @serviceNameColor The custom OAuth service name color (just stylizing).
* @buttonColor The color of the custom OAuth button (just stylizing).
* @buttonColor The custom OAuth button color (just stylizing).
* @see [enableOauthView]
*/
fun addCustomOauthServiceButton(
......@@ -208,6 +208,23 @@ interface LoginView : LoadingView, MessageView {
buttonColor: Int
)
/**
* Adds a SAML button in the oauth view.
*
* @samlUrl The SAML url to sets up the button (the listener).
* @serviceName The SAML service name.
* @serviceNameColor The SAML service name color (just stylizing).
* @buttonColor The SAML button color (just stylizing).
* @see [enableOauthView]
*/
fun addSamlServiceButton(
samlUrl: String,
samlToken: String,
serviceName: String,
serviceNameColor: Int,
buttonColor: Int
)
/**
* Setups the FloatingActionButton to show more social accounts views (expanding the oauth view interface to show the remaining view(s)).
*/
......
......@@ -25,8 +25,8 @@ import chat.rocket.android.authentication.login.presentation.LoginView
import chat.rocket.android.helper.KeyboardHelper
import chat.rocket.android.helper.TextHelper
import chat.rocket.android.util.extensions.*
import chat.rocket.android.webview.cas.ui.INTENT_CAS_TOKEN
import chat.rocket.android.webview.cas.ui.casWebViewIntent
import chat.rocket.android.webview.sso.ui.INTENT_SSO_TOKEN
import chat.rocket.android.webview.sso.ui.ssoWebViewIntent
import chat.rocket.android.webview.oauth.ui.INTENT_OAUTH_CREDENTIAL_SECRET
import chat.rocket.android.webview.oauth.ui.INTENT_OAUTH_CREDENTIAL_TOKEN
import chat.rocket.android.webview.oauth.ui.oauthWebViewIntent
......@@ -42,12 +42,12 @@ import kotlinx.android.synthetic.main.fragment_authentication_log_in.*
import timber.log.Timber
import javax.inject.Inject
internal const val REQUEST_CODE_FOR_CAS = 1
internal const val REQUEST_CODE_FOR_OAUTH = 2
internal const val MULTIPLE_CREDENTIALS_READ = 3
internal const val NO_CREDENTIALS_EXIST = 4
internal const val SAVE_CREDENTIALS = 5
internal const val REQUEST_CODE_FOR_SAML = 2
internal const val REQUEST_CODE_FOR_OAUTH = 3
internal const val MULTIPLE_CREDENTIALS_READ = 4
internal const val NO_CREDENTIALS_EXIST = 5
internal const val SAVE_CREDENTIALS = 6
lateinit var googleApiClient: GoogleApiClient
......@@ -121,7 +121,10 @@ class LoginFragment : Fragment(), LoginView, GoogleApiClient.ConnectionCallbacks
if (data != null) {
when (requestCode) {
REQUEST_CODE_FOR_CAS -> data.apply {
presenter.authenticateWithCas(getStringExtra(INTENT_CAS_TOKEN))
presenter.authenticateWithCas(getStringExtra(INTENT_SSO_TOKEN))
}
REQUEST_CODE_FOR_SAML -> data.apply {
presenter.authenticateWithSaml(getStringExtra(INTENT_SSO_TOKEN))
}
REQUEST_CODE_FOR_OAUTH -> {
isOauthSuccessful = true
......@@ -371,7 +374,7 @@ class LoginFragment : Fragment(), LoginView, GoogleApiClient.ConnectionCallbacks
ui { activity ->
button_cas.setOnClickListener {
startActivityForResult(
activity.casWebViewIntent(casUrl, casToken),
activity.ssoWebViewIntent(casUrl, casToken),
REQUEST_CODE_FOR_CAS
)
activity.overridePendingTransition(R.anim.slide_up, R.anim.hold)
......@@ -566,7 +569,7 @@ class LoginFragment : Fragment(), LoginView, GoogleApiClient.ConnectionCallbacks
buttonColor: Int
) {
ui { activity ->
val button = getCustomOauthButton(serviceName, serviceNameColor, buttonColor)
val button = getCustomServiceButton(serviceName, serviceNameColor, buttonColor)
social_accounts_container.addView(button)
button.setOnClickListener {
......@@ -579,6 +582,27 @@ class LoginFragment : Fragment(), LoginView, GoogleApiClient.ConnectionCallbacks
}
}
override fun addSamlServiceButton(
samlUrl: String,
samlToken: String,
serviceName: String,
serviceNameColor: Int,
buttonColor: Int
) {
ui { activity ->
val button = getCustomServiceButton(serviceName, serviceNameColor, buttonColor)
social_accounts_container.addView(button)
button.setOnClickListener {
startActivityForResult(
activity.ssoWebViewIntent(samlUrl, samlToken),
REQUEST_CODE_FOR_SAML
)
activity.overridePendingTransition(R.anim.slide_up, R.anim.hold)
}
}
}
override fun setupFabListener() {
ui {
button_fab.isVisible = true
......@@ -696,9 +720,9 @@ class LoginFragment : Fragment(), LoginView, GoogleApiClient.ConnectionCallbacks
}
/**
* Gets a stylized custom OAuth button.
* Gets a stylized custom service button.
*/
private fun getCustomOauthButton(
private fun getCustomServiceButton(
buttonText: String,
buttonTextColor: Int,
buttonBgColor: Int
......
......@@ -40,8 +40,11 @@ fun String.safeUrl(): String {
fun String.serverLogoUrl(favicon: String) = "${removeTrailingSlash()}/$favicon"
fun String.casUrl(serverUrl: String, token: String) =
"${removeTrailingSlash()}?service=${serverUrl.removeTrailingSlash()}/_cas/$token"
fun String.casUrl(serverUrl: String, casToken: String) =
"${removeTrailingSlash()}?service=${serverUrl.removeTrailingSlash()}/_cas/$casToken"
fun String.samlUrl(provider: String, samlToken: String) =
"${removeTrailingSlash()}/_saml/authorize/$provider/$samlToken"
fun String.termsOfServiceUrl() = "${removeTrailingSlash()}/terms-of-service"
......
......@@ -36,7 +36,6 @@ class OauthWebViewActivity : AppCompatActivity() {
private lateinit var state: String
private var isWebViewSetUp: Boolean = false
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_web_view)
......
package chat.rocket.android.webview.cas.ui
package chat.rocket.android.webview.sso.ui
import android.annotation.SuppressLint
import android.app.Activity
......@@ -13,19 +13,23 @@ import chat.rocket.android.R
import kotlinx.android.synthetic.main.activity_web_view.*
import kotlinx.android.synthetic.main.app_bar.*
fun Context.casWebViewIntent(webPageUrl: String, casToken: String): Intent {
return Intent(this, CasWebViewActivity::class.java).apply {
fun Context.ssoWebViewIntent(webPageUrl: String, casToken: String): Intent {
return Intent(this, SsoWebViewActivity::class.java).apply {
putExtra(INTENT_WEB_PAGE_URL, webPageUrl)
putExtra(INTENT_CAS_TOKEN, casToken)
putExtra(INTENT_SSO_TOKEN, casToken)
}
}
private const val INTENT_WEB_PAGE_URL = "web_page_url"
const val INTENT_CAS_TOKEN = "cas_token"
const val INTENT_SSO_TOKEN = "cas_token"
class CasWebViewActivity : AppCompatActivity() {
/**
* This class is responsible to handle the authentication thought single sign-on protocol (CAS and SAML).
*/
class SsoWebViewActivity : AppCompatActivity() {
private lateinit var webPageUrl: String
private lateinit var casToken: String
private var isWebViewSetUp: Boolean = false
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
......@@ -34,7 +38,7 @@ class CasWebViewActivity : AppCompatActivity() {
webPageUrl = intent.getStringExtra(INTENT_WEB_PAGE_URL)
requireNotNull(webPageUrl) { "no web_page_url provided in Intent extras" }
casToken = intent.getStringExtra(INTENT_CAS_TOKEN)
casToken = intent.getStringExtra(INTENT_SSO_TOKEN)
requireNotNull(casToken) { "no cas_token provided in Intent extras" }
setupToolbar()
......@@ -42,7 +46,10 @@ class CasWebViewActivity : AppCompatActivity() {
override fun onResume() {
super.onResume()
setupWebView()
if (!isWebViewSetUp) {
setupWebView()
isWebViewSetUp = true
}
}
override fun onBackPressed() {
......@@ -64,15 +71,16 @@ class CasWebViewActivity : AppCompatActivity() {
web_view.settings.javaScriptEnabled = true
web_view.webViewClient = object : WebViewClient() {
override fun onPageStarted(view: WebView, url: String, favicon: Bitmap?) {
// The user may have already been logged in the CAS, so check if the URL contains the "ticket" word
// (that means the user is successful authenticated and we don't need to wait until the page is fully loaded).
if (url.contains("ticket")) {
// The user may have already been logged in the SSO, so check if the URL contains
// the "ticket" or "validate" word (that means the user is successful authenticated
// and we don't need to wait until the page is fully loaded).
if (url.contains("ticket") || url.contains("validate")) {
closeView(Activity.RESULT_OK)
}
}
override fun onPageFinished(view: WebView, url: String) {
if (url.contains("ticket")) {
if (url.contains("ticket") || url.contains("validate")) {
closeView(Activity.RESULT_OK)
} else {
view_loading.hide()
......@@ -83,7 +91,7 @@ class CasWebViewActivity : AppCompatActivity() {
}
private fun closeView(activityResult: Int = Activity.RESULT_CANCELED) {
setResult(activityResult, Intent().putExtra(INTENT_CAS_TOKEN, casToken))
setResult(activityResult, Intent().putExtra(INTENT_SSO_TOKEN, casToken))
finish()
overridePendingTransition(R.anim.hold, R.anim.slide_down)
}
......
This diff is collapsed.
......@@ -36,7 +36,12 @@ ext {
junit : '4.12',
truth : '0.36',
espresso : '3.0.2',
mockito : '2.10.0'
mockito : '2.10.0',
//For wearable
wear : '2.3.0',
playServicesWearable : '15.0.1',
supportWearable : '26.1.0'
]
libraries = [
kotlin : "org.jetbrains.kotlin:kotlin-stdlib-jre8:${versions.kotlin}",
......@@ -103,5 +108,13 @@ ext {
espressoIntents : "com.android.support.test.espresso:espresso-intents:${versions.espresso}",
roomTest : "android.arch.persistence.room:testing:${versions.room}",
truth : "com.google.truth:truth:$versions.truth",
//For the wear app
wearable : "com.google.android.support:wearable:${versions.wear}",
playServicesWearable : "com.google.android.gms:play-services-wearable:${versions.playServicesWearable}",
percentLayout : "com.android.support:percent:${versions.supportWearable}",
supportWearable : "com.android.support:support-v4:${versions.supportWearable}",
wearableRecyclerView : "com.android.support:recyclerview-v7:${versions.supportWearable}",
wearSupport : "com.android.support:wear:${versions.supportWearable}"
]
}
include ':app', ':player', ':draw'
\ No newline at end of file
include ':app', ':player', ':draw', ':wear'
\ No newline at end of file
apply plugin: 'com.android.application'
apply plugin: 'kotlin-android'
apply plugin: 'kotlin-android-extensions'
android {
compileSdkVersion 26
buildToolsVersion versions.buildTools
defaultConfig {
applicationId "chat.rocket.android.wear"
minSdkVersion 23
targetSdkVersion versions.targetSdk
versionCode 1
versionName "1.0.0"
}
buildTypes {
release {
postprocessing {
removeUnusedCode false
removeUnusedResources false
obfuscate false
optimizeCode false
proguardFile 'proguard-rules.pro'
}
}
}
}
dependencies {
implementation fileTree(dir: 'libs', include: ['*.jar'])
implementation libraries.kotlin
implementation libraries.wearable
implementation libraries.playServicesWearable
implementation libraries.percentLayout
implementation libraries.supportWearable
implementation libraries.wearableRecyclerView
implementation libraries.wearSupport
compileOnly 'com.google.android.wearable:wearable:2.3.0'
}
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="chat.rocket.android.wear">
<uses-feature android:name="android.hardware.type.watch" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<application
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:supportsRtl="true"
android:theme="@android:style/Theme.DeviceDefault">
<uses-library
android:name="com.google.android.wearable"
android:required="true" />
<!--
Set to true if your app is Standalone, that is, it does not require the handheld
app to run.
-->
<meta-data
android:name="com.google.android.wearable.standalone"
android:value="true" />
<activity
android:name=".MainActivity"
android:label="@string/app_name">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>
\ No newline at end of file
package chat.rocket.android.wear
import android.os.Bundle
import android.support.wearable.activity.WearableActivity
class MainActivity : WearableActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
// Enables Always-on
setAmbientEnabled()
}
}
<?xml version="1.0" encoding="utf-8"?>
<android.support.wear.widget.BoxInsetLayout 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"
android:background="@color/dark_grey"
android:padding="@dimen/box_inset_layout_padding"
tools:context="chat.rocket.android.wear.MainActivity"
tools:deviceIds="wear">
<FrameLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:padding="@dimen/inner_frame_layout_padding"
app:boxedEdges="all">
<TextView
android:id="@+id/text"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/hello_world" />
</FrameLayout>
</android.support.wear.widget.BoxInsetLayout>
<resources>
<string name="hello_world">Hello Round World!</string>
</resources>
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!--
Because the window insets on round devices are larger than 15dp, this padding only applies
to square screens.
-->
<dimen name="box_inset_layout_padding">0dp</dimen>
<!--
This padding applies to both square and round screens. The total padding between the buttons
and the window insets is box_inset_layout_padding (above variable) on square screens and
inner_frame_layout_padding (below variable) on round screens.
-->
<dimen name="inner_frame_layout_padding">5dp</dimen>
</resources>
<resources>
<string name="app_name">Rocket&#46;Chat</string>
<!--
This string is used for square devices and overridden by hello_world in
values-round/strings.xml for round devices.
-->
<string name="hello_world">Hello Square World!</string>
</resources>
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment