웹사이트 검색

안드로이드 구글 스마트 락


이 자습서에서는 Smart Lock 기능에 대해 논의하고 Android 애플리케이션에서 구현합니다.

구글 스마트 락

Smart Lock은 자격 증명을 한 번에 저장하여 애플리케이션에 자동으로 로그인하는 데 사용됩니다. 즉, 잠시 후 애플리케이션을 다시 설치하면 Chrome 비밀번호에서 삭제하지 않았다면 이전에 저장한 자격 증명으로 자동 로그인할 수 있습니다.

Google Smart Lock을 사용하면 탭 한 번으로 로그인할 수 있습니다.

애플리케이션에 Smart Lock을 통합하려면 Credentials API를 사용해야 합니다. 자격 증명 API를 통해 사용자는 다음을 수행할 수 있습니다.

  • 앱을 열 때 자격 증명을 요청합니다.
  • 로그인 양식에서 자격 증명을 저장합니다.
  • 앱과 웹사이트 간에 자격 증명을 동기화합니다.
  • 로그인/가입 프로세스에서 사용자를 돕고자 하는 경우 이메일 힌트를 표시합니다.

애플리케이션에서 Google Smart Lock을 사용하려면 다음 종속성을 추가해야 합니다.

dependencies {
    implementation 'com.google.android.gms:play-services-auth:16.0.0'
}

SmartLock은 Android 애플리케이션에서 GoogleApiClient를 설정해야 합니다. SmartLock은 자격 증명이 하나만 있을 때 자동 로그인을 허용합니다. 둘 이상의 자격 증명이 있는 경우 대화 상자에 표시됩니다.

이전에는 자격 증명을 자동 서명하고 로컬에 저장하기 위해 SharedPreferences에 의존했습니다. 이제 Google Smart Lock을 사용하면 모든 것이 Google 서버에서 처리됩니다.

다음은 Credentials API에 있는 주요 메서드입니다.

  • 저장(GoogleApiClient 클라이언트, 자격 증명 자격 증명)
  • request(GoogleApiClient 클라이언트, CredentialRequestrequest) - 앱에 대해 저장된 모든 자격 증명을 요청합니다.
  • getHintPickerIntent(GoogleApiClient 클라이언트, HintRequest 요청) - 로그인 양식을 빠르게 작성하기 위해 로그인한 계정 목록을 표시합니다.
  • disableAutoSignIn(GoogleApiClient 클라이언트)
  • delete(GoogleApiClient 클라이언트, 자격 증명 자격 증명)

passwords.google.com으로 이동하여 Google 계정에 저장된 모든 자격 증명을 볼 수 있습니다. Smart Lock을 사용하는 애플리케이션의 흐름은 무엇입니까? 다음과 같은 방식으로 로그인 화면 코드를 구성해야 합니다.

  • 자격 증명을 확인합니다. 단일 자격 증명이 있는 경우 로그인 양식을 자동으로 서명하거나 자동으로 채웁니다.
  • 인증 정보가 두 개 이상인 경우 대화 상자에 표시하고 사용자가 선택하도록 합니다.
  • 저장된 자격 증명이 없는 경우 사용자가 양식을 채우도록 하거나 자동 완성 또는 로그인할 수 있는 사용 가능한 계정이 있는 힌트 대화 상자를 표시하여 더 쉽게 만들 수 있습니다.

시작하기

Android 애플리케이션에서 Smart Lock 기능 구현을 시작하겠습니다. GoogleApiClient 설정

mGoogleApiClient = new GoogleApiClient.Builder(this)
                .addConnectionCallbacks(this)
                .addApi(Auth.CREDENTIALS_API)
                .enableAutoManage(this, this)
                .build();

GoogleApiClient 인터페이스를 구현하고 메서드를 구현합니다. 자격 증명 클라이언트 초기화

CredentialsOptions options = new CredentialsOptions.Builder()
                .forceEnableSaveDialog()
                .build();


CredentialsClient  mCredentialsApiClient = Credentials.getClient(this, options);

forceEnableSaveDialog()는 Android Oreo 이상에 필요합니다. CredentialRequest 만들기

CredentialRequest mCredentialRequest = new CredentialRequest.Builder()
                .setPasswordLoginSupported(true)
                .setAccountTypes(IdentityProviders.GOOGLE)
                .build();

자격 증명 검색

Auth.CredentialsApi.request(mGoogleApiClient, mCredentialRequest).setResultCallback(this);

setResultCallBack을 사용하려면 ResultCallback 인터페이스에서 onResult 메서드를 재정의해야 합니다.

@Override
    public void onResult(@NonNull CredentialRequestResult credentialRequestResult) {

        Status status = credentialRequestResult.getStatus();
        if (status.isSuccess()) {
            onCredentialRetrieved(credentialRequestResult.getCredential());
        } else {
            if (status.getStatusCode() == CommonStatusCodes.RESOLUTION_REQUIRED) {
                try {
                    isResolving = true;
                    status.startResolutionForResult(this, RC_READ);
                } catch (IntentSender.SendIntentException e) {
                    Log.d(TAG, e.toString());
                }
            } else {

                showHintDialog();
            }
        }
    }

세 가지 경우가 있습니다 -

단일 자격 증명 - 성공 - 여러 자격 증명 - 문제를 해결하고 사용 가능한 모든 자격 증명을 대화 상자에 표시

  • 자격 증명 없음 - 사용 가능한 모든 로그인 계정이 있는 힌트 대화 상자 표시

RESOLUTION_REQUIRED 상태 코드는 해결해야 할 자격 증명이 두 개 이상 있음을 의미합니다. 이를 위해 onActivityResult 메서드에서 결과를 반환하는 startResolutionForResult를 호출합니다. 다중 해결이 발생하는 것을 방지하기 위해 부울 플래그를 사용합니다. 그러면 여러 대화 상자가 작성됩니다. SmartLock 기능의 요지를 살펴보았으니 이제 자격 증명 저장 및 삭제 기능으로 완전히 구현해 보겠습니다.

프로젝트 구조

암호

activity_main.xml 레이아웃의 코드는 다음과 같습니다.


<?xml version="1.0" encoding="utf-8"?>
<android.support.constraint.ConstraintLayout xmlns:android="https://schemas.android.com/apk/res/android"
    xmlns:app="https://schemas.android.com/apk/res-auto"
    xmlns:tools="https://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:importantForAutofill="noExcludeDescendants">


    <Button
        android:id="@+id/btnLogin"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginLeft="8dp"
        android:layout_marginRight="8dp"
        android:layout_marginTop="24dp"
        android:text="Login"
        app:layout_constraintLeft_toLeftOf="parent"
        app:layout_constraintRight_toRightOf="parent"
        app:layout_constraintTop_toBottomOf="@+id/inPassword" />

    <EditText
        android:id="@+id/inEmail"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:layout_marginLeft="16dp"
        android:layout_marginRight="16dp"
        android:layout_marginTop="32dp"
        android:ems="10"
        android:hint="email"
        android:inputType="textEmailAddress"
        app:layout_constraintHorizontal_bias="0.503"
        app:layout_constraintLeft_toLeftOf="parent"
        app:layout_constraintRight_toRightOf="parent" />


    <EditText
        android:id="@+id/inPassword"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:layout_marginLeft="16dp"
        android:layout_marginRight="16dp"
        android:layout_marginTop="8dp"
        android:ems="10"
        android:hint="password"
        android:inputType="textPassword"
        app:layout_constraintLeft_toLeftOf="parent"
        app:layout_constraintRight_toRightOf="parent"
        app:layout_constraintTop_toBottomOf="@+id/inEmail" />


</android.support.constraint.ConstraintLayout>

android:importantForAutofill=\noExcludeDescendants\>는 EditText 필드에서 자동 채우기를 비활성화하는 데 사용됩니다. Autofill API에 대해서는 별도의 자습서에서 설명하겠습니다. MainActivity.java 클래스의 코드는 다음과 같습니다.

package com.journaldev.androidgooglesmartlock;

import android.app.PendingIntent;
import android.content.Intent;
import android.content.IntentSender;
import android.support.annotation.NonNull;
import android.support.annotation.Nullable;
import android.support.v7.app.AppCompatActivity;
import android.os.Bundle;
import android.text.TextUtils;
import android.util.Log;
import android.util.Patterns;
import android.view.View;
import android.widget.Button;
import android.widget.EditText;
import android.widget.Toast;

import com.google.android.gms.auth.api.Auth;
import com.google.android.gms.auth.api.credentials.Credential;
import com.google.android.gms.auth.api.credentials.CredentialPickerConfig;
import com.google.android.gms.auth.api.credentials.CredentialRequest;
import com.google.android.gms.auth.api.credentials.CredentialRequestResponse;
import com.google.android.gms.auth.api.credentials.CredentialRequestResult;
import com.google.android.gms.auth.api.credentials.Credentials;
import com.google.android.gms.auth.api.credentials.CredentialsClient;
import com.google.android.gms.auth.api.credentials.CredentialsOptions;
import com.google.android.gms.auth.api.credentials.HintRequest;
import com.google.android.gms.auth.api.credentials.IdentityProviders;
import com.google.android.gms.auth.api.signin.GoogleSignIn;
import com.google.android.gms.auth.api.signin.GoogleSignInAccount;
import com.google.android.gms.auth.api.signin.GoogleSignInClient;
import com.google.android.gms.auth.api.signin.GoogleSignInOptions;
import com.google.android.gms.common.ConnectionResult;
import com.google.android.gms.common.api.ApiException;
import com.google.android.gms.common.api.CommonStatusCodes;
import com.google.android.gms.common.api.GoogleApiClient;
import com.google.android.gms.common.api.ResolvableApiException;
import com.google.android.gms.common.api.ResultCallback;
import com.google.android.gms.common.api.Status;
import com.google.android.gms.tasks.OnCompleteListener;
import com.google.android.gms.tasks.Task;

import java.util.regex.Pattern;

public class MainActivity extends AppCompatActivity implements GoogleApiClient.ConnectionCallbacks, GoogleApiClient.OnConnectionFailedListener, ResultCallback<CredentialRequestResult> {

    private GoogleApiClient mGoogleApiClient;
    CredentialsClient mCredentialsApiClient;
    CredentialRequest mCredentialRequest;
    public static final String TAG = "API123";
    private static final int RC_READ = 3;
    private static final int RC_SAVE = 1;
    private static final int RC_HINT = 2;
    boolean isResolving;

    Button btnLogin;
    EditText inEmail, inPassword;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);

        setUpGoogleApiClient();

        //needed for Android Oreo.
        CredentialsOptions options = new CredentialsOptions.Builder()
                .forceEnableSaveDialog()
                .build();


        mCredentialsApiClient = Credentials.getClient(this, options);
        createCredentialRequest();

        btnLogin = findViewById(R.id.btnLogin);
        inEmail = findViewById(R.id.inEmail);
        inPassword = findViewById(R.id.inPassword);

        btnLogin.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View view) {

                String email = inEmail.getText().toString();
                String password = inPassword.getText().toString();
                if (TextUtils.isEmpty(email) || TextUtils.isEmpty(password) || !Patterns.EMAIL_ADDRESS.matcher(email).matches())
                    showToast("Please enter valid email and password");

                else {

                    Credential credential = new Credential.Builder(email)
                            .setPassword(password)
                            .build();

                    saveCredentials(credential);
                }

            }
        });
    }

    public void setUpGoogleApiClient() {


        mGoogleApiClient = new GoogleApiClient.Builder(this)
                .addConnectionCallbacks(this)
                .addApi(Auth.CREDENTIALS_API)
                .enableAutoManage(this, this)
                .build();
    }

    public void createCredentialRequest() {
        mCredentialRequest = new CredentialRequest.Builder()
                .setPasswordLoginSupported(true)
                .setAccountTypes(IdentityProviders.GOOGLE)
                .build();
    }

    public void requestCredentials() {
        Auth.CredentialsApi.request(mGoogleApiClient, mCredentialRequest).setResultCallback(this);
    }


    private void onCredentialRetrieved(Credential credential) {
        String accountType = credential.getAccountType();
        if (accountType == null) {
            // Sign the user in with information from the Credential.
            gotoNext();
        } else if (accountType.equals(IdentityProviders.GOOGLE)) {


            GoogleSignInOptions gso = new GoogleSignInOptions.Builder(GoogleSignInOptions.DEFAULT_SIGN_IN)
                    .requestEmail()
                    .build();

            GoogleSignInClient signInClient = GoogleSignIn.getClient(this, gso);
            Task<GoogleSignInAccount> task = signInClient.silentSignIn();

            task.addOnCompleteListener(new OnCompleteListener<GoogleSignInAccount>() {
                @Override
                public void onComplete(@NonNull Task<GoogleSignInAccount> task) {
                    if (task.isSuccessful()) {
                        // See "Handle successful credential requests"
                        populateLoginFields(task.getResult().getEmail(), null);
                    } else {
                        showToast("Unable to do a google sign in");
                    }
                }
            });
        }
    }


    public void gotoNext() {
        startActivity(new Intent(this, SecondActivity.class));
        finish();
    }


    public void showToast(String s) {
        Toast.makeText(getApplicationContext(), s, Toast.LENGTH_SHORT).show();
    }


    @Override
    public void onConnected(@Nullable Bundle bundle) {
        Log.d("API123", "onConnected");
        requestCredentials();

    }

    @Override
    public void onConnectionSuspended(int i) {

    }

    @Override
    public void onConnectionFailed(@NonNull ConnectionResult connectionResult) {

    }

    @Override
    protected void onDestroy() {
        mGoogleApiClient.disconnect();
        super.onDestroy();
    }

    @Override
    public void onResult(@NonNull CredentialRequestResult credentialRequestResult) {

        Status status = credentialRequestResult.getStatus();
        if (status.isSuccess()) {
            onCredentialRetrieved(credentialRequestResult.getCredential());
        } else {
            if (status.getStatusCode() == CommonStatusCodes.RESOLUTION_REQUIRED) {
                try {
                    isResolving = true;
                    status.startResolutionForResult(this, RC_READ);
                } catch (IntentSender.SendIntentException e) {
                    Log.d(TAG, e.toString());
                }
            } else {

                showHintDialog();
            }
        }
    }

    @Override
    protected void onActivityResult(int requestCode, int resultCode, Intent data) {
        super.onActivityResult(requestCode, resultCode, data);

        Log.d(TAG, "onActivityResult");
        if (requestCode == RC_READ) {
            if (resultCode == RESULT_OK) {
                Credential credential = data.getParcelableExtra(Credential.EXTRA_KEY);
                onCredentialRetrieved(credential);
            } else {
                Log.d(TAG, "Request failed");
            }
            isResolving = false;
        }

        if (requestCode == RC_HINT) {
            if (resultCode == RESULT_OK) {
                Credential credential = data.getParcelableExtra(Credential.EXTRA_KEY);
                populateLoginFields(credential.getId(), "");
            } else {
                showToast("Hint dialog closed");
            }
        }

        if (requestCode == RC_SAVE) {
            if (resultCode == RESULT_OK) {
                Log.d(TAG, "SAVE: OK");
                gotoNext();
                showToast("Credentials saved");
            }
        }


    }

    public void populateLoginFields(String email, String password) {
        if (!TextUtils.isEmpty(email))
            inEmail.setText(email);

        if (!TextUtils.isEmpty(password))
            inPassword.setText(password);
    }

    public void showHintDialog() {
        HintRequest hintRequest = new HintRequest.Builder()
                .setHintPickerConfig(new CredentialPickerConfig.Builder()
                        .setShowCancelButton(true)
                        .build())
                .setEmailAddressIdentifierSupported(true)
                .setAccountTypes(IdentityProviders.GOOGLE)
                .build();

        PendingIntent intent = mCredentialsApiClient.getHintPickerIntent(hintRequest);
        try {
            startIntentSenderForResult(intent.getIntentSender(), RC_HINT, null, 0, 0, 0);
        } catch (IntentSender.SendIntentException e) {
            Log.e(TAG, "Could not start hint picker Intent", e);
        }
    }

    public void saveCredentials(Credential credential) {


        mCredentialsApiClient.save(credential).addOnCompleteListener(new OnCompleteListener<Void>() {
            @Override
            public void onComplete(@NonNull Task<Void> task) {
                if (task.isSuccessful()) {
                    Log.d(TAG, "SAVE: OK");
                    showToast("Credentials saved");
                    return;
                }

                Exception e = task.getException();
                if (e instanceof ResolvableApiException) {
                    // Try to resolve the save request. This will prompt the user if
                    // the credential is new.
                    ResolvableApiException rae = (ResolvableApiException) e;
                    try {
                        rae.startResolutionForResult(MainActivity.this, RC_SAVE);
                    } catch (IntentSender.SendIntentException f) {
                        // Could not resolve the request
                        Log.e(TAG, "Failed to send resolution.", f);
                        showToast("Saved failed");
                    }
                } else {
                    // Request has no resolution
                    showToast("Saved failed");
                }
            }
        });

    }
}

onConnected 메서드에서 사용 가능한 자격 증명을 요청합니다. 즉, 활동이 시작되는 즉시 자격 증명이 검색됩니다. 단일 자격 증명이 있는 경우 자동 서명하고 다음 활동으로 이동합니다. activity_second.xml 레이아웃의 코드는 다음과 같습니다.

<?xml version="1.0" encoding="utf-8"?>
<android.support.constraint.ConstraintLayout xmlns:android="https://schemas.android.com/apk/res/android"
    xmlns:app="https://schemas.android.com/apk/res-auto"
    xmlns:tools="https://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".SecondActivity">

    <Button
        android:id="@+id/btnDeleteAccount"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginTop="8dp"
        android:text="Delete account"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toBottomOf="@+id/btnSignOut" />

    <TextView
        android:id="@+id/textView"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="You are logged in."
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent" />

    <Button
        android:id="@+id/btnSignOut"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginTop="8dp"
        android:text="SIGN OUT"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toBottomOf="@+id/textView" />

    <Button
        android:id="@+id/btnSignOutDisableAutoSign"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="SIGN OUT AND DISABLE AUTO SIGN IN"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toBottomOf="@+id/btnDeleteAccount" />
</android.support.constraint.ConstraintLayout>

SecondActivity 내에서 로그아웃, 로그아웃 및 다음에 자동 로그인 비활성화, 자격 증명 삭제의 세 가지 작업을 수행합니다. SecondActivity.java 클래스의 코드는 다음과 같습니다.

package com.journaldev.androidgooglesmartlock;

import android.content.Intent;
import android.content.IntentSender;
import android.support.annotation.NonNull;
import android.support.annotation.Nullable;
import android.support.v7.app.AppCompatActivity;
import android.os.Bundle;
import android.util.Log;
import android.view.View;
import android.widget.Button;
import android.widget.Toast;

import com.google.android.gms.auth.api.Auth;
import com.google.android.gms.auth.api.credentials.Credential;
import com.google.android.gms.auth.api.credentials.CredentialRequest;
import com.google.android.gms.auth.api.credentials.CredentialRequestResult;
import com.google.android.gms.auth.api.credentials.Credentials;
import com.google.android.gms.auth.api.credentials.CredentialsClient;
import com.google.android.gms.common.ConnectionResult;
import com.google.android.gms.common.api.GoogleApiClient;
import com.google.android.gms.common.api.ResultCallback;
import com.google.android.gms.common.api.Status;

public class SecondActivity extends AppCompatActivity implements View.OnClickListener, GoogleApiClient.ConnectionCallbacks, GoogleApiClient.OnConnectionFailedListener, ResultCallback<CredentialRequestResult> {


    Button btnSignOut, btnSignOutDisableAuto, btnDelete;
    private GoogleApiClient mGoogleApiClient;
    CredentialsClient mCredentialsApiClient;
    CredentialRequest mCredentialRequest;
    public static final String TAG = "API123";
    private static final int RC_REQUEST = 4;


    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_second);

        setUpGoogleApiClient();
        mCredentialsApiClient = Credentials.getClient(this);


        btnSignOut = findViewById(R.id.btnSignOut);
        btnSignOutDisableAuto = findViewById(R.id.btnSignOutDisableAutoSign);
        btnDelete = findViewById(R.id.btnDeleteAccount);

        btnSignOut.setOnClickListener(this);
        btnSignOutDisableAuto.setOnClickListener(this);
        btnDelete.setOnClickListener(this);
    }

    @Override
    public void onClick(View view) {
        switch (view.getId()) {
            case R.id.btnSignOut:
                signOut(false);
                break;
            case R.id.btnSignOutDisableAutoSign:
                signOut(true);
                break;
            case R.id.btnDeleteAccount:
                requestCredentials();

                break;
        }
    }

    @Override
    public void onConnected(@Nullable Bundle bundle) {

    }

    @Override
    public void onConnectionSuspended(int i) {

    }

    @Override
    public void onConnectionFailed(@NonNull ConnectionResult connectionResult) {

    }

    @Override
    public void onResult(@NonNull CredentialRequestResult credentialRequestResult) {

        Status status = credentialRequestResult.getStatus();
        if (status.isSuccess()) {
            onCredentialSuccess(credentialRequestResult.getCredential());
        } else {
            if (status.hasResolution()) {
                try {
                    status.startResolutionForResult(this, RC_REQUEST);
                } catch (IntentSender.SendIntentException e) {
                    Log.d(TAG, e.toString());
                }
            } else {
                showToast("Request Failed");
            }
        }
    }

    public void setUpGoogleApiClient() {

        mGoogleApiClient = new GoogleApiClient.Builder(this)
                .addConnectionCallbacks(this)
                .addApi(Auth.CREDENTIALS_API)
                .enableAutoManage(this, this)
                .build();
    }


    private void requestCredentials() {
        mCredentialRequest = new CredentialRequest.Builder()
                .setPasswordLoginSupported(true)
                .build();

        Auth.CredentialsApi.request(mGoogleApiClient, mCredentialRequest).setResultCallback(this);
    }


    @Override
    protected void onDestroy() {
        mGoogleApiClient.disconnect();
        super.onDestroy();
    }

    private void onCredentialSuccess(Credential credential) {

        Auth.CredentialsApi.delete(mGoogleApiClient, credential).setResultCallback(new ResultCallback<Status>() {
            @Override
            public void onResult(@NonNull Status status) {
                if (status.isSuccess()) {
                    signOut(false);
                } else {
                    showToast("Account Deletion Failed");
                }
            }
        });


    }

    @Override
    protected void onActivityResult(int requestCode, int resultCode, Intent data) {
        super.onActivityResult(requestCode, resultCode, data);
        if (requestCode == RC_REQUEST) {
            if (resultCode == RESULT_OK) {
                showToast("Deleted");
                Credential credential = data.getParcelableExtra(Credential.EXTRA_KEY);
                onCredentialSuccess(credential);
            } else {
                Log.d(TAG, "Request failed");
            }
        }
    }

    public void showToast(String s) {
        Toast.makeText(getApplicationContext(), s, Toast.LENGTH_SHORT).show();
    }

    private void signOut(boolean disableAutoSignIn) {

        if (disableAutoSignIn)
            Auth.CredentialsApi.disableAutoSignIn(mGoogleApiClient);

        startActivity(new Intent(this, MainActivity.class));
        finish();
    }
}

기계적 인조 인간GoogleSmartLock

Github 프로젝트 링크