너의 이름은 영화에 나온 MyDiary를 구현한 오픈소스 앱 똑같이 따라 코딩해보기

게시일:

Overview

두 달이라는 기간동안 안드로이드 어플리케이션을 개발하기 위해 열중하였고 결국 aintstagram이라는 완성본을 만들게 되었다. 하지만 구현을 하는 도중에 이게 최선의 방법일까라는 고민을 수도 없이 하였고 좀 더 좋은 방법이 분명 존재할 것 같다는 생각을 자주 하게 되었다. 그래서 이번에는 다른 개발자가 구현한 앱의 코드 흐름을 그대로 따라가면서 좀 더 효율적인 코딩을 하기 위한 방법을 배우고자 한다.

Target

인스타그램 클론 프로젝트를 구현하는 동안 가장 많이 사용한 것은 RecyclerView와 관련된 내용들이었다. 그래서 이 기능을 활용하는 앱을 따라해보아야겠다는 생각을 하였다. 그리하여 선택한 앱은 MyDiary라는 앱이다.

MyDiary

해당 앱은 너의 이름은 이라는 애니메이션에 등장한 다이어리 어플을 구현한 것으로 MIT 라이센스로 무려 500회가 넘는 커밋기록과 1400개 이상의 star를 받은 작품이다. 해당 앱을 따라하는 과정에서 많은 것들을 배울것으로 확신하고 타겟으로 정하게 되었다. 무엇보다 가장 좋은 점은 따라하는 프로젝트다보니 이미지 관련부분을 고민하지 않아도 된다는 점이다. 물론 코드를 이해해야 겠지만..

코딩시작

InitActivity

어플리케이션을 리버싱 할 때 가장 먼저 확인하는 것이 AndroidManifest였다. 시작점을 찾기가 용이하기 떄문이랄까? 코딩도 마찬가지였다. MAIN 부터 프로그램이 흐름을 따라가기 시작하기 때문에 이를 찾아서 해당 지점부터 구현을 진행하기로 하였다. 매니페스트를 통해 InitActivity가 splash효과를 위해 사용된 것을 확인할 수 있었다. 그 전에 여러 resource 정보들을 추가하기로 하였는데 layout을 제외하고 drawable과 color, 그리고 styles 정보를 복사해왔다.

InitActivity 쪽을 보니 onCreate부분에서는 핸들러에 대한 초기화와 View에 대한 할당을 진행하는 것 외의 별다른 특징은 보이지 않았다. onCreate -> onStart -> onResume의 단계에서 사용자와의 커뮤니케이션을 시작하는 부분인 onResume에서 SPFManager로부터 메소드를 호출하는 것을 확인할 수 있었다. 이제 SPFManager가 무엇인지를 확인할 차례이다.

SPF는 SharedPreference의 줄임말인듯하다. 왜냐하면 getLocalLanguageCode와 같은 메소드들이 SharedPreference로부터 값을 가져오고 setConfigLocalLanguage 등의 메소드가 SharedPreference의 값을 설정하기 때문이었다.

public static int getLocalLanguageCode(Context context){
    SharedPreferences settings = context.getSharedPreferences(SPF_CONFIG, 0);
    return settings.getInt(CONFIG_LOCAL_LANGUAGE, 0);
}

가져오는 방식은 대부분 동일하였다. settings로 SPF_CONFIG를 가져온 뒤 특정 값을 return으로 넘겨주는 방식이었다.

public static void setConfigLocalLanguage(Context context, int languageCode){
    SharedPreferences settings = context.getSharedPreferences(SPF_CONFIG, 0);
    SharedPreferences.Editor PE = settings.edit();
    PE.putInt(CONFIG_LOCAL_LANGUAGE, languageCode);

    // Changed it as apply is more faster than commit which means async
    PE.apply();
}

마찬가지로 set도 같은 방식으로 정보를 가져온 뒤 edit를 할 수 있게 Editor를 할당한다. 그 후 putInt와 같은 명령어를 통해 해당 값을 넣어주게 된다. 원본의 코드에서는 PE.commit()이 사용되었는데 해당 예제와 같이 return 값에 대한 처리가 필요하지 않은 경우에는 apply로 바꿈으로써 비동기로 진행하여 더 빠른 처리를 할 수 있다고 한다. 그리하여 apply로 모든 코드를 변경하였다.

protected void onResume() {
    super.onResume();
    //This apk is first install or was updated
    if (SPFManager.getVersionCode(InitActivity.this) < BuildConfig.VERSION_CODE) {
        TV_init_message.setVisibility(View.VISIBLE);
    }
    initHandler.postDelayed(new Runnable() {
        @Override
        public void run() {
            new InitTask(InitActivity.this, InitActivity.this).execute();
        }
    }, initTime);
}

InitTask는 onResume 부분에서 initTime인 2500(2.5초)의 대기시간을 가진 뒤 postDelayed에 의해 호출이 된다. InitTask는 AsyncTask를 상속받는데 AsyncTask의 선언방식에 대해서 알아보았다.

public class InitTask extends AsyncTask<Long, Void, Boolean> {

3가지 파라미터는 각각 다른 의미를 내포한다. 첫 번째 파라미터는 background 작업에 사용할 data 자료형을 의미하며, background 작업 진행 표시를 위해 사용할 인자가 두 번째 파라미터, 작업의 결과를 표현할 자료형을 세 번째 파라미터로 지정해준다.

AsyncTask 또한 특정 루틴이 존재하는데 onPreExecuted는 백그라운드 작업이 활성화되기 전에 수행되며 만약 AsyncTask가 이미지 관련 작업을 처리 중인 상태라면 로딩 중임을 표시하는 이미지 띄워넣기 등의 작업을 수행하기 위해 사용된다. 그 후 백그라운드 작업을 수행하게 되는데 doInBackground는 execute를 호출한 당시를 인자로 받아서 수행을 하게 되는데 위 예제에서 context와 callback이 이에 해당된다. 그리고 publishProgress와 같이 진행 상태를 나타내는데 사용되는 메소드들이 존재하며 doInBackground 작업이 마무리되면 onPostExecuted가 호출이 되면서 결과를 리턴해주어 쓰레드 작업이 끝난 뒤의 행동을 수행하도록 한다. 다만 execute의 호출을 UIThread에서 해주어야 되며 Task가 한 번만 실행가능하다는 단점이 있다.

InitTask에서는 doInBackground 부근에서는 DB로부터 sampleData를 가져오는 역할을 수행하고 onPostExecute는 결과값을 InitCallback 인터페이스의 onInitCompiled 메소드로 반환하게 된다.

doInBackground 부분에서 SQLite DB와 관련된 작업을 수행하는데 이를 위해 db라는 패키지가 존재한다. 안드로이드의 SQLite DataBase는 Contract, Helper, Database로 크게 나뉘는데 Contract는 코드상에서 DBStructure에 해당되며 어떤식으로 계약을 진행할지 나타내는 문서가 되며 Helper는 계약서의 내용을 가져와 CRUD 작업을 수행하며 이를 위한 DB구조가 DBStructure에 명시되어 있다고 보면 된다.

public class DBStructure {

    public static abstract class DiaryEntry implements BaseColumns {
        public static final String TABLE_NAME = "diary_entry";
        public static final String COLUMN_TIME = "diary_time";
        //Fix  diary_count -> diary_title in V2
        public static final String COLUMN_TITLE = "diary_count";
        public static final String COLUMN_CONTENT = "diary_content";
        public static final String COLUMN_MOOD = "diary_mood";
        public static final String COLUMN_WEATHER = "diary_weather";
        public static final String COLUMN_ATTACHMENT = "diary_attachment";
        public static final String COLUMN_REF_TOPIC__ID = "diary_ref_topic_id";
        public static final String COLUMN_LOCATION = "diary_location";
    }

DBStructure 클래스는 BaseColumns로 구현하는데 DiaryEntry에 대한 Column정보를 상수 문자열로 정의해준다. diary_entry라는 이름을 가진 계약서는 time, title, content 등의 컬럼을 가지게 된다.

private static final String SQL_CREATE_DIARY_ENTRIES =
        "CREATE TABLE " + DiaryEntry.TABLE_NAME + " (" +
                DiaryEntry._ID + INTEGER_TYPE + " PRIMARY KEY AUTOINCREMENT," +
                DiaryEntry.COLUMN_TIME + INTEGER_TYPE + COMMA_SEP +
                DiaryEntry.COLUMN_TITLE + TEXT_TYPE + COMMA_SEP +
                DiaryEntry.COLUMN_CONTENT + TEXT_TYPE + COMMA_SEP +
                DiaryEntry.COLUMN_MOOD + INTEGER_TYPE + COMMA_SEP +
                DiaryEntry.COLUMN_WEATHER + INTEGER_TYPE + COMMA_SEP +
                DiaryEntry.COLUMN_ATTACHMENT + INTEGER_TYPE + COMMA_SEP +
                DiaryEntry.COLUMN_REF_TOPIC__ID + INTEGER_TYPE + COMMA_SEP +
                DiaryEntry.COLUMN_LOCATION + TEXT_TYPE + COMMA_SEP +
                FOREIGN + " (" + DiaryEntry.COLUMN_REF_TOPIC__ID + ")" + REFERENCES + TopicEntry.TABLE_NAME + "(" + TopicEntry._ID + ")" +
                " )";

helper부분을 보면 SQL_CREATE_DIARY_ENTRIES라는 문자열을 볼 수 있는데, DBStructure에서 정의한 값들에 대한 쿼리문을 나태는데 사용된다.

public void onCreate(SQLiteDatabase db) {
    db.execSQL(SQL_CREATE_TOPIC_ENTRIES);
    db.execSQL(SQL_CREATE_TOPIC_ORDER);

    //Diary V2 work from db version 4
    db.execSQL(SQL_CREATE_DIARY_ENTRIES_V2);
    db.execSQL(SQL_CREATE_DIARY_ITEM_ENTRIES_V2);

    //Add memo order table in version 6
    db.execSQL(SQL_CREATE_MEMO_ENTRIES);
    db.execSQL(SQL_CREATE_MEMO_ORDER);

    db.execSQL(SQL_CREATE_CONTACTS_ENTRIES);
}

DBHelper의 onCreate부분을 보면 execSQL로 db에 테이블을 생성해주는데 db상에 이미 존재할 경우 예외가 발생되기 때문에 주의해서 사용하여야 한다.

public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) {
    if (newVersion > oldVersion) {
        try {
            db.beginTransaction();
            if (oldVersion < 2) {
                oldVersion++;
                String addLocationSql = "ALTER TABLE  " + DiaryEntry.TABLE_NAME + " ADD COLUMN " + DiaryEntry.COLUMN_LOCATION + " " + TEXT_TYPE;
                String addTopicOrderSql = "ALTER TABLE  " + TopicEntry.TABLE_NAME + " ADD COLUMN " + TopicEntry.COLUMN_ORDER + " " + INTEGER_TYPE;
                db.execSQL(addLocationSql);
                db.execSQL(addTopicOrderSql);
                db.execSQL(SQL_CREATE_MEMO_ENTRIES);
            }

                //Check update success
            db.setTransactionSuccessful();
        } finally {
            db.endTransaction();
        }

버전의 경우 newVersion과 oldVersion을 확인한 뒤 sql문을 작성하여 execSQL을 하는 것을 확인할 수 있다.

public void openDB() throws SQLException{
    mDBHelper = new DBHelper(context);
    this.db = mDBHelper.getWritableDatabase();
}

public void closeDB() {
    mDBHelper.close();
}

DBManger 부분의 opeDB는 위에서 정의한 Helper를 정의한 뒤 getWritableDatabase()를 호출하여 데이터베이스 객체를 가져온다.

public void beginTransaction(){
    db.beginTransaction();
}

public void setTransactionSuccessful(){
    db.setTransactionSuccessful();
}

public void endTransaction(){
    db.endTransaction();
}

쿼리 작업을 할 때 호출되는 트랜젝션 관련 명령어도 DBManager의 해당 함수들을 호출함으로써 제어하게 된다.

public long insertTopic(String name, int type, int color) {
    return db.insert(
            TopicEntry.TABLE_NAME,
            null,
            this.createTopicCV(name, type, color));
}

public long insertTopicOrder(long topicId, long order) {
    ContentValues values = new ContentValues();
    values.put(TopicOrderEntry.COLUMN_ORDER, order);
    values.put(TopicOrderEntry.COLUMN_REF_TOPIC__ID, topicId);
    return db.insert(
            TopicOrderEntry.TABLE_NAME,
            null,
            values);
}

public long updateTopic(long topicId, String name, int color) {
    ContentValues values = new ContentValues();
    values.put(TopicEntry.COLUMN_NAME, name);
    values.put(TopicEntry.COLUMN_COLOR, color);
    return db.update(
            TopicEntry.TABLE_NAME,
            values,
            TopicEntry._ID + " = ?",
            new String[]{String.valueOf(topicId)});
}

Topic과 관련된 메소드들을 보면 ContentValues 인스턴스를 생성하여 put으로 갑들을 넣은 뒤 insert를 통해 db에 삽입을 한다.

public Cursor selectTopic() {
    Cursor c = db.rawQuery("SELECT * FROM " + TopicEntry.TABLE_NAME
                    + " LEFT OUTER JOIN " + TopicOrderEntry.TABLE_NAME
                    + " ON " + TopicEntry._ID + " = " + TopicOrderEntry.COLUMN_REF_TOPIC__ID
                    + " ORDER BY " + TopicOrderEntry.COLUMN_ORDER + " DESC "
            , null);
    if (c != null) {
        c.moveToFirst();
    }
    return c;
}

selectTopic은 rawQuery라는 명령어로 직접 쿼리문을 작성하였는데 LEFT OUTER JOIN이라는 키워드가 보인다.

JOIN의 종류설명

이는 테이블이 연관된 경우 교집합, 합집합 등을 가져오기 위한 것으로 LEFT OUTER JOIN을 사용한 위 쿼리는 TopicEntry의 내용을 조회하되 TopicEntry의 ID를 REF_TOPIC_ID로 가지고 있는 TopicOrderEntry들에 대한 값도 같이 가져온다는 의미이며 이렇게 가져온 값을 COLUMN_ORDER를 기준으로 DESC 즉, 내림차 순으로 정렬해서 가져온다는 의미이다. 위의 블로그에서 다이어그램을 확인하면 쉽게 이해가 가능하다.

FileUtils

프로젝트 내에서 FileUtils를 활용하여 아래와 같은 처리를 수행하는 코드들이 존재한다.

 FileUtils.deleteDirectory(destDir);

apache에서 배포한 것으로 org.apache.commons.io.FileUtils를 import하여 사용할 수 있다. 이를 사용하기 위해서는 gradle에서 implementation을 추가해주면 된다. 현재 버전이 2.7까지 나온것으로 확인되지만 해당 프로젝트에서 사용한 버전이 2.5이기 때문에 이를 사용하도록 한다.

implementation 'commons-io:commons-io:2.5'
public class MyDiaryApplication extends Application {

    boolean hasPassword = false;

    @Override
    public void onCreate() {
        super.onCreate();
        //Use Fresco
        Set<RequestListener> listeners = new HashSet<>();
        listeners.add(new RequestLoggingListener());
        ImagePipelineConfig config = ImagePipelineConfig.newBuilder(this)
                .setRequestListeners(listeners)
                .setDownsampleEnabled(true)
                .build();
        Fresco.initialize(this, config);

MyDiaryApplication 부분을 보면 Application으로 정의가 되어 있음을 확인할 수 있다. Application으로 정의된 클래스는 안드로이드 컴포넌트들 사이에서 공유가 가능한 클래스로 공동으로 사용될 항목들을 작성해두면 context를 통해 접근이 가능하다. 그리고 이렇게 생성된 Application은 매니페스트에 정의가 되어 있어야 된다.

Fresco는 페이스북에 만든 라이브러리로 이미지와 관련된 작업을 수행하는데 효율적이다. 캐싱을 지원해서 속도적인 측면에서도 우위를 점할 수 있는데, Application 레벨에서 초기화가 진행되어야 하기 때문에 어플리케이션의 onCreate에 추가되었다.

AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_NO);

AndroidQ부터 NIGHT 시간에는 테마의 색깔이 변하는 설정이 있는데 위의 코드로 해당 기능을 비활성화해준다.

PasswordActivity

패스워드가 설정된 경우 호출이 되는 액티비티로 주요 기능을 사용자로부터 패스워드를 입력받아 설정 또는 검증을 한다. 방식을 보면 로직은 간단하다. passwordPointer를 두고 사용자로부터 4자리를 입력받는다. 받을 때마다 pos를 옮기면서 조건문을 수행하며 그 과정에서 ImageResource를 입력결과에 따라 변경해준다. PorterDuff를 사용해서 원본이미지가 덮어씌어졌을때 이미지에 대한 처리도 진행한다.

private void afterPasswordChanged() {
    switch (currentMode) {
        case CREATE_PASSWORD:
            createdPassword = passwordStrBuilder.toString();
            clearUiPassword();
            currentMode = CREATE_PASSWORD_WITH_VERIFY;
            initUI();
            break;
        case CREATE_PASSWORD_WITH_VERIFY:
            if (createdPassword.equals(passwordStrBuilder.toString())) {
                setPassword(Encryption.SHA256(passwordStrBuilder.toString()));
                ((MyDiaryApplication) getApplication()).setHasPassword(true);
                finish();
            } else {
                clearUiPassword();
                setSubMessage();
            }
            break;

Mode에 따라 타는 루틴이 다른데 setPassword부분은 SharedPreference에 패스워드 값을 넣어주는 역할을 한다. 그 인자로 들어가는 값은 사용자로부터 버튼에 클릭된 4자리의 숫자 문자열의 암호화값인데 이를 위해 Encryption이라는 클래스가 존재한다.

public class Encryption {

    public static String Encrypt(String str, String encryptionMethod){
        String encoded;

        MessageDigest md = null;
        try {
            md = MessageDigest.getInstance(encryptionMethod);
            md.update(str.getBytes());
            byte[] byteData = md.digest();
            StringBuffer sb = new StringBuffer();
            for(byte aByteData : byteData){
                sb.append(Integer.toString((aByteData & 0xff) + 0x100, 16).substring(1));
            }
            encoded = sb.toString();
        } catch (NoSuchAlgorithmException e) {
            e.printStackTrace();
            encoded = null;
        }
        return encoded;
    }
}

Encrypt는 Method정보를 받아서 수행하는데 MD5, SHA-256이 이에 해당된다. 이렇게 받은 정보를 토대로 digest를 수행한 뒤 append를 수행하는데 보면 0xff로 앤드 연산을 처리한 뒤 0x100을 더하고, substring(1)을 하는 행위를 반복한다.

어떤 행위인지 궁금해서 찾아봤더니 설명된 블로그가 존재하였다.

byte to hexstrings

쉽게 설명해서 16진수 값의 범위를 나타내기 위함인데 0x10전까지는 한자리의 값인 0x1, 0xf 등을 가지므로 이를 맞춰주기 위해서 0x101, 0x10f와 같이 만들어준 뒤, 자릿수를 맞추기 위해 맨 앞에 더해진 1을 빼서 0x01, 0x0f와 같이 만들어주는 과정이다. 이를 통해 동일한 자릿수를 만들 수 있다. 이렇게 해쉬화 된 값을 가지고 결과를 확인하는 행위를 하는 액티비티이다.

MainActivity

PasswordActivity 또는 InitActivity가 호출된 직후에 처리되는 루틴인데 본격적으로 RecyclerView가 사용되기 시작한다. 여기서 참신한 라이브러리를 알게 되는데 android-advancedrecyclerview이다. 직접 구현했던 여러 swipe과 같은 기능들을 오픈소스로 구현해둔 것으로 아래의 깃에서 확인이 가능하다.

advanced recyclerView

ITopic을 자료로 사용하는 MainTopicAdapter 부분을 분해해 볼 차례이다.

public interface ITopic {
    /**
     * The contacts , Mitsuha  and Taki change their cell phone number in this function.
     */
    int TYPE_CONTACTS = 0;
    /**
     * Mitsuha and Taki write daily diary when their soul change.
     */
    int TYPE_DIARY = 1;

    /**
     * Mitsuha and Taki add some memo to notice that something can't do.
     */
    int TYPE_MEMO = 2;

    String getTitle();

    /**
     * For update topic
     */
    void setTitle(String title);

    int getType();

    long getId();

    @DrawableRes
    int getIcon();

    /**
     * For update count in Main Page
     */
    void setCount(long count);

    long getCount();

    int getColor();

    /**
     * For update topic
     */
    void setColor(int color);

    /**
     * For the left swipe
     */

    void setPinned(boolean pinned);

    boolean isPinned();
}

나와는 다르게 interface를 ArrayList로 생성하여 사용하는 것을 확인할 수 있다. 그로 인하여 세부적인 구현에 대한 부분은 존재하지 않는다.

public MainTopicAdapter(MainActivity activity, List<ITopic> topicList, DBManager dbManager){
    this.activity = activity;
    this.originalTopicList = topicList;
    this.filteredTopicList = new ArrayList<>();
    this.dbManager = dbManager;
}

생성자의 경우도 context를 받는게 아닌 명시적으로 어떤 액티비티에서 들어올지를 판단하여 해당 액티비티를 변수로 가지고 있다는 점에서도 차이가 발생한다.

public class TopicViewHolder extends AbstractSwipeableItemViewHolder {

레이아웃과 연결해주는데 사용하는 TopicViewHoler의 정의 부분을 보면 AbstractSwipeableItemViewHolder를 상속받아 라이브러리를 적용한다는 점 또한 확인이 가능하다.

private static class TopicFilter extends Filter {

    private final MainTopicAdapter adapter;
    private final List<ITopic> originalList;
    private final List<ITopic> filteredList;
    private boolean isFilter = false;

    private TopicFilter(MainTopicAdapter adapter, List<ITopic> originalList){
        super();
        this.adapter = adapter;
        this.originalList = originalList;
        this.filteredList = new ArrayList<>();
    }

    @Override
    protected FilterResults performFiltering(CharSequence constraint) {
        filteredList.clear();

        final FilterResults results = new FilterResults();

        if(constraint.length() == 0){
            filteredList.addAll(originalList);
            isFilter = false;
        } else {
            final String filterPattern = constraint.toString().toLowerCase().trim();
            for(final ITopic topic : originalList){
                if(topic.getTitle().toLowerCase().contains(filterPattern)){
                    filteredList.add(topic);
                }
            }
            isFilter = true;
        }
        results.values = filteredList;
        results.count = filteredList.size();
        return results;
    }

    @Override
    protected void publishResults(CharSequence constraint, FilterResults results) {
        adapter.filteredTopicList.clear();
        adapter.filteredTopicList.addAll((ArrayList<ITopic>) results.values);
        adapter.notifyDataSetChanged(false);
    }

Filterable은 인스타그램 프로젝트에서도 사용한 적이 있는데 Charsequence를 바탕으로 필터링을 진행하는 performFiltering과 filtering 작업이 끝난뒤에 호출되는 publishResults가 정의되어 있다. 방식은 내 코드와 거의 비슷하다고 봐도 무방한데 filteredPattern에 lowercase로 검색하여 조건에 만족하는 값들을 filteredlist로 옮긴 뒤 기존에 존재하던 filteredTopicList를 지우고 덮어씌우는 역할을 수행한다.

OnCreateViewHolder 부분에서는 RecyclerView의 아이템으로 사용할 레이아웃을 연결하는 작업만 수행하고 onBindViewHolder에서 나와 다르게 onClickListener를 추가해준 것이 확인이 가능하였다. 나라면 TopicViewHolder의 생성자부분에 listener를 달아주었을 것 같다. 아무래도 onBindViewHolder는 Holder를 바꾸는 과정에서 매번 호출이 되기 때문에 한 번만 수행해도 되는 행위가 여러분 수행되기 때문이다. 그래서 나는 다음과 같이 코드를 변경하였다.

@NonNull
@Override
public TopicViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
    View view = LayoutInflater.from(parent.getContext()).inflate(R.layout.rv_topic_item, parent, false);
    final TopicViewHolder holder = new TopicViewHolder(view);

    holder.getRLTopic().setOnClickListener(new View.OnClickListener(){
        @Override
        public void onClick(View v) {
            gotoTopic(filteredTopicList.get(holder.getAdapterPosition()).getType(), holder.getAdapterPosition());
        }
    });

    holder.getTopicLeftSettingEditView().setOnClickListener(new View.OnClickListener() {
        @Override
        public void onClick(View v) {
            // TODO
        }
    });

    holder.getTopicLeftSettingDeleteView().setOnClickListener(new View.OnClickListener() {
        @Override
        public void onClick(View v) {
            // TODO
        }
    });

    return holder;
}

holder의 position이 직접적으로 들어오지 않기 떄문에 getAdapterPosition을 활용하여 Adapter로부터 위치 정보를 가져오도록 한다. 이렇게 되면 예상치 못하게 pos 정보가 달라지는 경우에도 정확한 위치를 알아낼 수 있다는 장점이 있다.

@Override
public boolean onCheckCanStartDrag(@NonNull TopicViewHolder holder, int position, int x, int y) {

    final View containerView = holder.getSwipeableContainerView();

    final int offsetX = containerView.getLeft() + (int)(ViewCompat.getTranslationX(containerView) + 0.5f);
    final int offsetY = containerView.getTop() + (int)(ViewCompat.getTranslationY(containerView) + 0.5f);

    return !topicFilter.isFilter() && ViewTools.hitTest(containerView, x-offsetX, y-offsetY);
}

Advanced RecyclerView를 위해서 구현해야 하는 메소드들이 몇개있는데 onCheckCanStartDrag는 행동을 하기 전에 해당 범위에 속해있는지를 검증할 때 사용된다. left좌표와 top의 좌표를 기준으로 holder의 크기 범위안에서 작업이 일어나는지를 확인하여 결과를 돌려준다.

@Override
public void onMoveItem(int fromPosition, int toPosition) {
    if(fromPosition == toPosition) return;

    final ITopic originalItem = originalTopicList.remove(fromPosition);
    originalTopicList.add(toPosition, originalItem);

    final ITopic filteredItem = filteredTopicList.remove(fromPosition);
    filteredTopicList.add(toPosition, filteredItem);

    int orderNumber = originalTopicList.size();
    dbManager.openDB();
    dbManager.deleteAllCurrentTopicOrder();
    for(ITopic topic : originalTopicList){
        dbManager.insertTopicOrder(topic.getId(), --orderNumber);
    }
    dbManager.closeDB();
    notifyDataSetChanged(false);
}

MoveEvent의 처리를 위한 방식으로 fromPosition의 값을 뺀 뒤, toPosition 뒤에 삽입한다. 그 후 dbManager를 통해 TopicOrder정보를 삭제한 뒤, 새롭게 추가한 뒤, adapter에 변화를 알리면서 끝을 낸다.

SwipeableItemAdapter

SwipeableItemAdapter 인터페이스를 implements 해주면 마찬가지로 오버라이딩이 필요한 메소드들이 존재한다.

public int onGetSwipeReactionType(@NonNull TopicViewHolder holder, int position, int x, int y) {
        if (ViewTools.hitTest(holder.getSwipeableContainerView(), x, y)) {
        return SwipeableItemConstants.REACTION_CAN_SWIPE_BOTH_H;
    } else {
        return SwipeableItemConstants.REACTION_CAN_NOT_SWIPE_BOTH_H;
    }
}

위와 같은 메소드인데 해당 enum값은 위의 링크에서 확인이 가능하다. onSetSwipeBackground의 경우 background color 등에 대한 정보를 변경할 수 있다고 한다.

@Nullable
@Override
public SwipeResultAction onSwipeItem(@NonNull TopicViewHolder holder, int position, int result) {
    switch (result){
        case SwipeableItemConstants.RESULT_SWIPED_RIGHT:
            return new SwipeRightResultAction(this, position);
        case SwipeableItemConstants.RESULT_SWIPED_LEFT:
        case SwipeableItemConstants.RESULT_CANCELED:
        default:
            if (position != RecyclerView.NO_POSITION) {
                return new UnpinResultAction(this, position);
            } else {
                return null;
            }                
    }
    return null;
}

onSwipeItem은 swap이벤트에 대한 처리를 담당하는 부분으로 RIGHT인 경우와 LEFT/CANCELED/DEFAULT인 경우로 나누어 처리해준다. 여기서 사용되는 리턴값은 재정의된 클래스이다.

다시 MainActivity의 onCreate로 돌아와서 보면 layout에 대한 할당 과정이 진행된 뒤에 initProfile이 호출이 된다.

private void initProfile() {
    String YourNameIs = SPFManager.getYourName(MainActivity.this);
    if (YourNameIs == null || "".equals(YourNameIs)){
        YourNameIs = themeManager.getThemeUserName(MainActivity.this);
    }
    TV_main_profile_username.setText(YourNameIs);
    LL_main_profile.setBackground(themeManager.getProfileBgDrawable(this));
}

해당 함수는 SharedPreference에 이름 정보가 없을 경우 default값을 세팅하는 용도로 사용된다. 또 다른 중요한 메소드인 initTopicAdapter을 보도록 하자.

private void initTopicAdapter() {
    mRecyclerViewSwipeManager = new RecyclerViewSwipeManager();

    // touch guard manager  (this class is required to suppress scrolling while swipe-dismiss animation is running)
    mRecyclerViewTouchActionGuardManager = new RecyclerViewTouchActionGuardManager();
    mRecyclerViewTouchActionGuardManager.setInterceptVerticalScrollingWhileAnimationRunning(true);
    mRecyclerViewTouchActionGuardManager.setEnabled(true);

    LinearLayoutManager lmr = new LinearLayoutManager(this);
    RecyclerView_topic.setLayoutManager(lmr);
    RecyclerView_topic.setHasFixedSize(true);
    mainTopicAdapter = new MainTopicAdapter(this, topicList, dbManager);
    mWrappedAdapter = mRecyclerViewSwipeManager.createWrappedAdapter(mainTopicAdapter);

    final GeneralItemAnimator animator = new DraggableItemAnimator();
    animator.setSupportsChangeAnimations(false);
    
    mRecyclerViewDragDropManager = new RecyclerViewDragDropManager();
    mRecyclerViewDragDropManager.setInitiateOnMove(false);
    mWrappedAdapter = mRecyclerViewDragDropManager.createWrappedAdapter(mWrappedAdapter);
    
    RecyclerView_topic.setAdapter(mWrappedAdapter);
    RecyclerView_topic.setItemAnimator(animator);

RecyclerView에 사용되는 여러 변수들이 초기화 된다. RecyclerView의 경우 LinearLayoutManager로 정의가 된 뒤 mainTopicAdapter로 빈 ArrayList와 같이 초기화 된다. 그 후 setAdapter를 통해 DragDropManager로 wrapped 된 adapter를 recyclerView에 붙혀준다.

초기화가 끝나게 되면 DB로부터 Topic에 대한 항목들을 가져오는데 그 항목들은 3가지로 Contacts, Diary, Memo가 이에 해당된다. ITopic 인터페이스를 상속받는 객체들로 loadTopic에서는 이에 대한 처리가 진행된다.

private void loadTopic() {
    topicList.clear();
    Cursor topicCursor = dbManager.selectTopic();
    for(int i=0; i<topicCursor.getCount(); i++){
        switch(topicCursor.getInt(2)){
            case ITopic.TYPE_CONTACTS:
                topicList.add(
                        new Contacts(topicCursor.getLong(0),
                                topicCursor.getString(1),topicCursor.getInt(5)));
                break;
            case ITopic.TYPE_DIARY:
                topicList.add(
                        new Diary(topicCursor.getLong(0),
                                topicCursor.getString(1),
                                topicCursor.getInt(5)));
                break;
            case ITopic.TYPE_MEMO:
                topicList.add(
                        new Memo(topicCursor.getLong(0),
                                topicCursor.getString(1),
                                topicCursor.getInt(5)));
                break;
        }
        topicCursor.moveToNext();
    }
    topicCursor.close();
}

topicList는 그 형태에 맞게 초기화가 진행된다.

public Contacts(long id, String title, int color) {
    this.id = id;
    this.title = title;
    this.color = color;
}

Contacts의 생성자를 보면 id, title, color에 대한 정보를 가져오는 것을 확인할 수 있다.

dbManager.openDB();
loadTopic();
dbManager.closeDB();
mainTopicAdapter.notifyDataSetChanged(true);

해당 작업이 마무리되면 notifyDataSetChanged로 viewholder에 들어갈 리스트에 대한 정보가 변경되었음을 알려준다. OOBE 부분은 사용자가 처음 실행하는 경우에 설명을 알려주기 위해서 호출되는 부분인데 생략하도록 한다.

여기까지하고 실행할 경우 unique_id 관련 문제가 발생한다. setHasStablesIds(boolean)을 adapter에 추가해주어야 되는데 이는 각각의 아이템 항목이 고유한 번호를 가지고 있음을 보장한다는 의미이다.

@Override
public long getItemId(int position) {
    return filteredTopicList.get(position).getId();
}

이를 위해서는 getItemID를 재정의하여 고유한 id를 가질 수 있도록 보장해주어야 된다.

if(getIntent().getBooleanExtra("showReleaseNote", false)){
    ReleaseNoteDialogFragment releaseNoteDialogFragment = new ReleaseNoteDialogFragment();
    releaseNoteDialogFragment.show(getSupportFragmentManager(), "releaseNoteDialogFragment");
}

onCreate의 마지막부분에 DiaglogFragment를 실행하는 부분이 있다. 액티비티위에 Dialog 형식으로 띄울 수 있는데 해당 기능을 구현하는 Fragment이다.

@NonNull
@Override
public Dialog onCreateDialog(@Nullable Bundle savedInstanceState) {
    Dialog dialog = super.onCreateDialog(savedInstanceState);
    dialog.getWindow().requestFeature(Window.FEATURE_NO_TITLE);

    return dialog;
}

@Nullable
@Override
public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) {
    this.getDialog().setCanceledOnTouchOutside(false);
    View rootView = inflater.inflate(R.layout.dialog_fragment_release_note, container);

    RL_release_note = (RelativeLayout) rootView.findViewById(R.id.RL_release_note);
    RL_release_note.setBackgroundColor(ThemeManager.getInstance().getThemeMainColor(getActivity()));

    TV_release_note_text = (TextView) rootView.findViewById(R.id.TV_release_note_text);
    TV_release_note_text.setText(getString(R.string.release_note));

    CTV_release_note_knew = (CheckedTextView) rootView.findViewById(R.id.CTV_release_note_knew);
    CTV_release_note_knew.setOnClickListener(this);

    But_release_note_ok = (MyDiaryButton) rootView.findViewById(R.id.But_release_note_ok);
    But_release_note_ok.setOnClickListener(this);
    return rootView;
}

onCreateDialog에서는 dialog를 생성한 뒤 onCreateView 부분에서 레이아웃 작업을 수행한다. 그리고 onClick 리스너를 추가하여 버튼에 대한 동작을 구현하면 끝나게 된다.

@Override
protected void onDestroy() {
    if (mRecyclerViewDragDropManager != null) {
        mRecyclerViewDragDropManager.release();
        mRecyclerViewDragDropManager = null;
    }
    if (mRecyclerViewSwipeManager != null) {
        mRecyclerViewSwipeManager.release();
        mRecyclerViewSwipeManager = null;
    }

    if (mRecyclerViewTouchActionGuardManager != null) {
        mRecyclerViewTouchActionGuardManager.release();
        mRecyclerViewTouchActionGuardManager = null;
    }

    if (RecyclerView_topic != null) {
        RecyclerView_topic.setItemAnimator(null);
        RecyclerView_topic.setAdapter(null);
        RecyclerView_topic = null;
    }

    if (mWrappedAdapter != null) {
        WrapperAdapterUtils.releaseAll(mWrappedAdapter);
        mWrappedAdapter = null;
    }
    mainTopicAdapter = null;
    super.onDestroy();
}

onDestroy 부분을 살펴보면 어댑터와 연결된 요소들에 대한 release를 수행하고 Destroy를 수행한다.

YourNameDialogFragment 부분으로 가보도록 하자. 기본적인 DialogFragment 구현 부분은 거의 동일하니 생략하도록 하고, onClick 이벤트 부분을 살펴보도록 한다.

@Override
public void onClick(View v) {
    switch (v.getId()) {
        case R.id.IV_your_name_profile_picture:
            if (PermissionHelper.checkPermission(this, REQUEST_WRITE_ES_PERMISSION)) {
                FileManager.startBrowseImageFile(this, SELECT_PROFILE_PICTURE_BG);
            }
            break;
        case R.id.IV_your_name_profile_picture_cancel:
            isAddNewProfilePicture = true;
            profilePictureFileName = "";
            IV_your_name_profile_picture.setImageDrawable(
                    ViewTools.getDrawable(getActivity(), R.drawable.ic_person_picture_default));
            break;
        case R.id.But_your_name_ok:
            saveYourName();
            callback.updateName();
            dismiss();
            break;
        case R.id.But_your_name_cancel:
            dismiss();
            break;
    }
}

Dialog에는 cancel과 dismiss가 있고 차이가 있다. dismiss는 thread-safe하며 dialog를 화면에서 지우는 역할을 수행한다. 그리고 cancel의 경우 back 버튼을 누르는 것과 같은 상황에서도 호출이 되는데 dismiss루틴을 타기는 하지만 그 전에 cancel listener가 존재하면 콜백을 호출한 뒤에 dismiss를 실행한다는 점에서 차이가 있다.

그리고 해당 부분에서 이미지를 가져오기 위해서 Permission을 확인해야되는데 이를 담당하는 PermissionHelper 클래스를 확인할 수 있다. 해당 부분을 구현할 차례이다.

public class PermissionHelper {

    public static final int REQUEST_ACCESS_FINE_LOCATION_PERMISSION = 1;
    public static final int REQUEST_CAMERA_AND_WRITE_ES_PERMISSION = 2;
    public static final int REQUEST_WRITE_ES_PERMISSION = 3;

    public static boolean checkPermission(Fragment fragment, final int requestCode){
        switch (requestCode){
            case REQUEST_ACCESS_FINE_LOCATION_PERMISSION:
                if(ContextCompat.checkSelfPermission(fragment.getActivity(),
                        Manifest.permission.ACCESS_FINE_LOCATION) != PackageManager.PERMISSION_GRANTED){
                    if(ActivityCompat.shouldShowRequestPermissionRationale(fragment.getActivity(),
                            Manifest.permission.ACCESS_FINE_LOCATION)){
                        fragment.requestPermissions(new String[]{Manifest.permission.ACCESS_FINE_LOCATION}, requestCode);
                        return false;
                    } else{
                        fragment.requestPermissions(new String[]{Manifest.permission.ACCESS_FINE_LOCATION}, requestCode);
                        return false;
                    }
                }
                break;
            }
        }
        return true;
    }
}

checkPermission 부분에서 사용자의 Location 정보를 가져오기 위한 권한 요청 부분을 보면 다음과 같다. checkSelfPermission을 호출하여 해당 권한에 대한 승인이 되어 있지 않은 경우 shouldShowRequestPermissionRationale을 호출하여 권한이 왜 필요한지를 설명하는 방법과 그 외 설명없이 권한을 요청하는 두 가지 방법을 수행하는데 결국 두 루틴 모두 requestPermission을 통해 ACCESS_FINE_LOCATION 권한을 요청하는 역할을 수행한다. 해당 함수 리턴형은 Boolean이기에 권한이 허용되어 있는 경우에만 true를 리턴하도록 설계되어 있다.

case R.id.IV_your_name_profile_picture:
    if (PermissionHelper.checkPermission(this, REQUEST_WRITE_ES_PERMISSION)) {
        FileManager.startBrowseImageFile(this, SELECT_PROFILE_PICTURE_BG);
    }
    break;

onClick이벤트를 통해 위의 루틴이 실행된 뒤 PermissionHelper가 권한에 대한 요청 및 처리를 마무리 하게 되면 FileManager의 startBrowseImageFile이 호출이 된다.

@Override
public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) {
    if (requestCode == PermissionHelper.REQUEST_WRITE_ES_PERMISSION) {
        if (grantResults.length > 0
                && PermissionHelper.checkAllPermissionResult(grantResults)) {
            FileManager.startBrowseImageFile(this, SELECT_PROFILE_PICTURE_BG);
        } else {
            PermissionHelper.showAddPhotoDialog(getActivity());
        }
    }
}

onRequestPermissionsResult 부분에서도 requestCode를 검사한 뒤 조건에 성립하면 Image Intent를 호출하는 것 또한 확인이 가능하다.

public static void startBrowseImageFile(Activity activity, int requestCode) {
    try {
        Intent intentImage = new Intent();
        intentImage.setType("image/*");
        intentImage.setAction(Intent.ACTION_GET_CONTENT);
        activity.startActivityForResult(Intent.createChooser(intentImage, "Select Picture"), requestCode);
    } catch (android.content.ActivityNotFoundException ex) {
        Log.e(TAG, ex.toString());
    }
}

해당 메소드는 ACTION_GET_CONTENT를 호출하여 사용자가 사진을 앨범으로부터 선택할 수 있도록 해준다.

private void saveYourName() {
    //Save name
    SPFManager.setYourName(getActivity(), EDT_your_name_name.getText().toString());
    //Save profile picture
    if (isAddNewProfilePicture) {
        //Remove the old file
        FileManager bgFM = new FileManager(getActivity(), FileManager.SETTING_DIR);
        File oldProfilePictureFile = new File(bgFM.getDirAbsolutePath()
                + "/" + ThemeManager.CUSTOM_PROFILE_PICTURE_FILENAME);
        if (oldProfilePictureFile.exists()) {
            oldProfilePictureFile.delete();
        }
        if (!"".equals(profilePictureFileName)) {
            try {
                //Copy the profile into setting dir
                FileManager.copy(
                        new File(tempFileManager.getDirAbsolutePath() + "/" + profilePictureFileName),
                        oldProfilePictureFile);
            } catch (Exception e) {
                e.printStackTrace();
                Toast.makeText(getActivity(), getString(R.string.toast_save_profile_picture_fail), Toast.LENGTH_SHORT).show();
            }
        }
    }
}

사용자가 이름을 변경 요청할 시 호출되는 메소드인 saveYourName을 보면 SPFManager를 통해 레이아웃으로부터 String값을 가져온 뒤 SharedPreference에 저장한다. 그 후 isAddNewProfilePicture을 검증하여 만약 프로파일에 대한 변화가 있을 경우에 getDirAbsolutePath을 호출하여 File에 대한 경로를 얻어온 뒤, 존재하는 경우에 삭제해준다. 그 후 이미지를 tempFileManager를 통해 oldProfilePictureFile로 복사함으로써 새로운 이미지를 적용하게 된다.

@Override
public void onActivityResult(int requestCode, int resultCode, @Nullable Intent data) {
    if (requestCode == SELECT_PROFILE_PICTURE_BG) {
        if (resultCode == RESULT_OK) {
            if (data != null && data.getData() != null) {

                //Create fileManager for get temp folder
                tempFileManager = new FileManager(getActivity(), FileManager.TEMP_DIR);
                tempFileManager.clearDir();
                //Compute the bg size
                int photoSize = ScreenHelper.dpToPixel(getResources(), 50);
                UCrop.Options options = new UCrop.Options();
                options.setToolbarColor(ThemeManager.getInstance().getThemeMainColor(getActivity()));
                options.setStatusBarColor(ThemeManager.getInstance().getThemeDarkColor(getActivity()));
                UCrop.of(data.getData(), Uri.fromFile(
                        new File(tempFileManager.getDir() + "/" + FileManager.createRandomFileName())))
                        .withMaxResultSize(photoSize, photoSize)
                        .withAspectRatio(1, 1)
                        .withOptions(options)
                        .start(getActivity(), this);
            } else {
                Toast.makeText(getActivity(), getString(R.string.toast_photo_intent_error), Toast.LENGTH_LONG).show();
            }
        }

onActivityResult 부분에서 처리과정을 살펴보면 Uri.fromFile을 통해 data로부터 file 정보를 가져와 new File을 생성하는데 디렉터리 정보와 randomFileName을 합친 값으로 생성을 진행한다.

public static String createRandomFileName() {
    return UUID.randomUUID().toString();
}

createRandomFileName은 randomUUID 정보를 가지고 생성하는데 인터넷을 찾아보니 고유 식별변호를 만들어주는 것으로 겹칠 확률이 희박하기 때문에 랜덤으로 이름정보를 생성할 시에 효율적으로 사용된다고 한다. 이렇게 파일 편집 라이브러리인 Ucrop의 src로 data.getData가, dst로 새로 만든 file이 들어가서 편집 작업이 수행된다.

} else if (requestCode == UCrop.REQUEST_CROP) {
    if (resultCode == RESULT_OK) {
        if (data != null) {
            final Uri resultUri = UCrop.getOutput(data);
            IV_your_name_profile_picture.setImageBitmap(BitmapFactory.decodeFile(resultUri.getPath()));
            profilePictureFileName = FileManager.getFileNameByUri(getActivity(), resultUri);
            isAddNewProfilePicture = true;
        } else {
            Toast.makeText(getActivity(), getString(R.string.toast_crop_profile_picture_fail), Toast.LENGTH_LONG).show();
        }
    }
}

그리고 해당 작업이 끝난 뒤에 IV_your_name_profile_picture의 이미지가 변경된다. 그리고 uCrop의 경우 Manifest에 다음과 같이 선언이 되어야 사용이 가능하다.

<!--Ucrop-->
<activity
    android:name="com.yalantis.ucrop.UCropActivity"
    android:screenOrientation="portrait"
    android:theme="@style/Theme.AppCompat.Light.NoActionBar" />

MainSettingDialogFragment는 BottomSheetDialogFragment를 상속받는데 원래는 com.android.support:design 였지만 바뀌고 나서는 아래의 라이브러리를 추가해주어야 된다.

implementation 'com.google.android.material:material:1.1.0'

참고로 compile이 deprecated 되고 implementation의 사용을 권장하는데 그에 대한 설명이 아래의 블로그에 있다.

compile vs implementation

쉽게 프로젝트를 빌드하는 순간에 implementation을 사용하게 되면 전체 빌드가 아니라 관련된 부분만 빌드가 된다고 이해하였다.

MainDetailDialogFragment는 adapter 내부에서 홀더의 왼쪽에 있는 버튼에 대한 기능을 구현하기 위해 존재하며 해당 Fragment를 통해 ITopic을 상속받는 객체에 대한 수정, 추가 작업이 진행된다.

holder.getTopicLeftSettingEditView().setOnClickListener(new View.OnClickListener() {
    @Override
    public void onClick(View v) {
        int position = holder.getAdapterPosition();
        TopicDetailDialogFragment createTopicDialogFragment =
                TopicDetailDialogFragment.newInstance(true, position,
                        filteredTopicList.get(position).getId(),
                        filteredTopicList.get(position).getTitle(),
                        filteredTopicList.get(position).getType(),
                        filteredTopicList.get(position).getColor());
        createTopicDialogFragment.show(activity.getSupportFragmentManager(), "createTopicDialogFragment");
    }
});

기존 코드와 다르게 listener를 생성자 쪽에 설치해주었기 때문에 pos는 getAdapterPosition을 통해 가져오도록 하였다.

public static TopicDetailDialogFragment newInstance(boolean isEditMode, int position, long topicId,
                                                    String title, int topicType, int topicColorCode){
    Bundle args = new Bundle();
    TopicDetailDialogFragment fragment = new TopicDetailDialogFragment();
    args.putBoolean("isEditMode", isEditMode);
    args.putInt("position", position);
    args.putString("title", title);
    args.putLong("topicId", topicId);
    args.putInt("topicType", topicType);
    args.putInt("topicColorCode", topicColorCode);
    fragment.setArguments(args);
    return fragment;
}

newInstance로 만들어주는 이유는 fragment또한 가비지 컬렉션의 타겟이기 때문에 인자로 넘어온 값들에 대한 유지가 필요하다. 하지만 fragment의 경우 재 생성시에 인자가 없는 생성자가 호출되기 떄문에 newInstance를 선언해줌으로써 내부적으로 Bundle에서 값을 다시 가져오는 루틴을 타게 한다. 즉 재생성 하는 순간에 Bundle에 대한 정보가 다시 넘어온다는 것이다.

추가/변경/삭제의 관점에서 해당 행위를 살펴보자면 다음과 같다.

@Override
public void TopicCreated(final String topicTitle, final int type, final int color) {
    dbManager.openDB();
    final long newTopicId = dbManager.insertTopic(topicTitle, type, color);
    topicList.add(0,
            new ITopic() {
                .. 생략
            });
    int orderNumber = topicList.size();
    dbManager.deleteAllCurrentTopicOrder();

    for(ITopic topic : topicList){
        dbManager.insertTopicOrder(topic.getId(), --orderNumber);
    }
    loadTopic();
    dbManager.closeDB();

    mainTopicAdapter.notifyDataSetChanged(true);
    EDT_main_topic_search.setText("");
}

topicList의 처음 인덱스에 새로 추가되는 데이터를 추가하고 그 뒤를 기존의 데이터로 재배치시킨다.

public void notifyDataSetChanged(boolean clear) {
    if(clear){
        filteredTopicList.clear();
        filteredTopicList.addAll(originalTopicList);
    }
    super.notifyDataSetChanged();
}

그 후 notifyDataSetChanged를 호출하는 과정에서 clear를 한 뒤, adapter의 전체 리스트에 대한 초기화를 진행한다.

@Override
public void TopicUpdated(int position, String newTopicTitle, int color, int topicBgStatus, String newBgFileName) {
    DBManager dbManager = new DBManager(this);
    dbManager.openDB();
    dbManager.updateTopic(mainTopicAdapter.getList().get(position).getId(), newTopicTitle, color);
    dbManager.closeDB();

    mainTopicAdapter.getList().get(position).setTitle(newTopicTitle);
    mainTopicAdapter.getList().get(position).setColor(color);
    mainTopicAdapter.notifyDataSetChanged(false);

    updateTopicBg(position, topicBgStatus, newBgFileName);
    EDT_main_topic_search.setText("");
}

update 부분의 경우 해당 부분에 대한 결과만 바꿔주면 되기 때문에 리스트를 재생성하지 않고 해당 부분에 대한 결과만 바꿔준다. 하지만 이부분도 비효율적인게 DataSet이 아닌 특정 하나의 아이템이 변경된 것이기 때문에 이를 다음과 같이 변경해준다.

@Override
public void TopicUpdated(int position, String newTopicTitle, int color, int topicBgStatus, String newBgFileName) {
    DBManager dbManager = new DBManager(this);
    dbManager.openDB();
    dbManager.updateTopic(mainTopicAdapter.getList().get(position).getId(), newTopicTitle, color);
    dbManager.closeDB();

    mainTopicAdapter.getList().get(position).setTitle(newTopicTitle);
    mainTopicAdapter.getList().get(position).setColor(color);
    mainTopicAdapter.notifyItemChanged(position);

    updateTopicBg(position, topicBgStatus, newBgFileName);
    EDT_main_topic_search.setText("");
}

position에 대한 정보또한 알고 있기 때문에 notifyItemChanged를 호출하며 position을 인자로 넘겨주면 해당 부분만 변경되었음을 알려줄 수 있다.

    for (int i = 0; i < topicList.size(); i++) {
        if (topicList.get(i).getId() == mainTopicAdapter.getList().get(position).getId()) {
            topicList.remove(i);
            break;
        }
    }

    mainTopicAdapter.getList().remove(position);

    mainTopicAdapter.notifyItemRemoved(position);
    mainTopicAdapter.notifyItemRangeChanged(position, mainTopicAdapter.getItemCount());

    EDT_main_topic_search.setText("");

삭제의 경우 list에서 해당 부분을 찾아내 삭제한 뒤, remove를 통해 adapter부분에서도 삭제를 진행한다. 그리고 notifyItemRemoved를 position 정보와 함께 호출하여 해당 부분이 삭제된 사실을 알리고 이로인하여 변경된 구간들을 notifyItemRangeChanged를 통해 최신화를 진행해준다.

SettingActivity

환경설정과 관련된 작업을 수행하는 곳이다.

public class SettingColorPickerFragment extends DialogFragment implements View.OnClickListener {

    public interface colorPickerCallback {
        void onColorChange(int colorCode, int viewId);
    }

    private int oldColor;
    private int viewId;

    private ColorPicker picker;
    private SVBar svBar;
    private Button But_setting_change_color, But_setting_cancel;

    private colorPickerCallback callback;

    @Override
    public void onAttach(@NonNull Context context) {
        super.onAttach(context);
        try {
            callback = (colorPickerCallback) context;
        } catch (ClassCastException e){
        }
    }

위와 같이 Fragment와 관련된 developer 문서를 보면 Activity에서 수행할 callback을 onAttach 부분에서 할당해주는데 이게 대체 무슨의미인지가 궁금하였다. 내가 알기로 context는 Application과 Activity 레벨로 나뉘어져 있으며 Context를 상속받아 특정 리소스에 접근을 하기 위해 사용되는 것으로 안다. 그런데 callback에 context를 할당한다는게 무슨뜻일까라는 고민을 가지고 검색을 한 결과 위의 예제의 colorPickerCallback 인터페이스 내부의 onColorChange 메소드에 대한 구현정보를 context를 통해 Activity로부터 가져와 casting 해주는 과정이다. 그렇기 떄문에 해당 메소드에 대한 정의가 되어있지 않은 경우 ClassCastException에러가 발생하게 된다.

private void applySetting(boolean killProcess) {
    //Restart App
    Intent i = this.getBaseContext().getPackageManager()
            .getLaunchIntentForPackage(this.getBaseContext().getPackageName());
    i.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK | Intent.FLAG_ACTIVITY_NEW_TASK);
    startActivity(i);

    if (killProcess) {
        android.os.Process.killProcess(android.os.Process.myPid());
        System.exit(0);
    } else {
        this.finish();
    }
}

다른 액티비티와 다른 기능을 수행하는 코드를 뽑자면 applySetting이 있다. Intent는 packageName을 가져옴으로써 본인을 intent로 설정한다. 그리고 Flags를 추가하는데 FLAG_ACTIVITY_CLEAR_TASK는 본인을 제외한 모든 액티비티를 종료하는 것을 의미한다. 해당 intent를 수행하게 되면 앱이 재시작되게 된다.

BackupActivity

com.nononsenseapps.filepicker.FilePickerActivity 라이브러리를 사용하여 구현된 Activity이다. 이를 위해서 먼저 DirectoryPickerActivity를 만든다.

public class DirectoryPickerActivity extends FilePickerActivity {

    private DirectoryPickerFragment currentFragment;

    @Override
    protected AbstractFilePickerFragment<File> getFragment(
            @Nullable String startPath, int mode, boolean allowMultiple,
            boolean allowCreateDir, boolean allowExistingFile, boolean singleClick) {
        String path = (startPath != null ? startPath : Environment.getExternalStorageDirectory().getPath());

        currentFragment = new DirectoryPickerFragment();
        currentFragment.setArgs(path, mode, allowMultiple, allowCreateDir, allowExistingFile, singleClick);
        return currentFragment;
    }

    @Override
    public void onBackPressed() {
        if (currentFragment.isBackTop()) {
            super.onBackPressed();
        } else {
            currentFragment.goUp();
        }
    }
}

FilePickerActivity를 상속한 DirectoryPickerActivity는 AbstractFilePickerFragment를 가져온다.

public class DirectoryPickerFragment extends FilePickerFragment {

    @RequiresApi(api = Build.VERSION_CODES.LOLLIPOP)
    @Override
    public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
        View rootView = super.onCreateView(inflater, container, savedInstanceState);

        Toolbar toolbar = (Toolbar) rootView.findViewById(com.nononsenseapps.filepicker.R.id.nnf_picker_toolbar);
        toolbar.setBackgroundColor(ThemeManager.getInstance().getThemeMainColor(getActivity()));

        recyclerView.setBackgroundColor(Color.WHITE);

        ((Button) rootView.findViewById(com.nononsenseapps.filepicker.R.id.nnf_button_cancel)).setText("Cancel");
        ((Button) rootView.findViewById(com.nononsenseapps.filepicker.R.id.nnf_button_ok)).setText("OK");

        return rootView;
    }

    public File getBackTop(){
        return getPath(getArguments().getString(KEY_START_PATH, "/"));
    }

    // if root path
    public boolean isBackTop(){
        return 0 == compareFiles(mCurrentPath, getBackTop()) ||
                0 == compareFiles(mCurrentPath, new File("/"));
    }

    // ..
    public void goUp(){
        mCurrentPath = getParent(mCurrentPath);
        mCheckedItems.clear();
        mCheckedVisibleViewHolders.clear();
        refresh(mCurrentPath);
    }
}

fragment에서는 getBackTop, isBackTop, goUp을 구현하는데 이는 경로와 관련된 부분을 설정한다.

case R.id.TV_backup_export_src:
    Intent exportIntent = new Intent(this, DirectoryPickerActivity.class);
    exportIntent.putExtra(FilePickerActivity.EXTRA_ALLOW_MULTIPLE, false);
    exportIntent.putExtra(FilePickerActivity.EXTRA_ALLOW_CREATE_DIR, true);
    exportIntent.putExtra(FilePickerActivity.EXTRA_MODE, FilePickerActivity.MODE_DIR);
    exportIntent.putExtra(FilePickerActivity.EXTRA_START_PATH, Environment.getExternalStorageDirectory().getPath());
    startActivityForResult(exportIntent, EXPORT_SRC_PICKER_CODE);
    break;

intent로 호출을 할 경우 flag값을 extra로 설정해서 보내줌으로써 실행이 가능하다.

<activity android:name=".backup.DirectoryPickerActivity"
    android:theme="@style/FilePickerTheme">
    <intent-filter>
        <action android:name="android.intent.action.GET_CONTENT"/>
        <category android:name="android.intent.category.DEFAULT"/>
    </intent-filter>
</activity>

그리고 manifest에 activity를 추가해주면 된다.

@Override
protected void onActivityResult(int requestCode, int resultCode, @Nullable Intent data) {
    super.onActivityResult(requestCode, resultCode, data);
    if (requestCode == EXPORT_SRC_PICKER_CODE && resultCode == Activity.RESULT_OK) {
        Uri uri = data.getData();
        if(uri != null) {
            File file = com.nononsenseapps.filepicker.Utils.getFileForUri(uri);

            if(file.canWrite()){
                TV_backup_export_src.setText(file.getAbsolutePath());
                But_backup_export.setEnabled(true);
            } else {
                Toast.makeText(this, getString(R.string.backup_export_can_not_write),
                        Toast.LENGTH_SHORT).show();
            }
        }
    } else if (requestCode == IMPORT_SRC_PICKER_CODE && resultCode == Activity.RESULT_OK) {
        Uri uri = data.getData();
        if(uri != null) {
            File file = com.nononsenseapps.filepicker.Utils.getFileForUri(uri);
            
            if(file.canRead()){
                TV_backup_import_src.setText(file.getAbsolutePath());
                But_backup_import.setEnabled(true);
            } else { 
                Toast.makeText(this, getString(R.string.backup_import_can_not_read),
                        Toast.LENGTH_SHORT).show();
            }
        }
    }
}

해당 부분이 실행되고 나서 Result 부분으로 돌아오게 되면 uri를 통해 File에 대한 정보를 가져온 뒤, 권한을 확인하고 path를 가져오고 setEnabled를 true로 바꾸어 버튼에 대한 리스너가 작동하도록 한다.

public ExportAsyncTask(Context context, ExportCallBack callBack, String backupZipRootPath){
    this.mContext = context;
    this.callBack = callBack;
    this.backupManager.initBackupManagerExportInfo();
    this.dbManager = new DBManager(context);

    FileManager backupFM = new FileManager(context, FileManager.BACKUP_DIR);

    this.backupJsonFilePath = backupFM.getDirAbsolutePath() + "/"
            + BackupManager.BACKUP_JSON_FILE_NAME;
    this.backupZipRootPath = backupZipRootPath;
    this.backupZipFileName = BACKUP_ZIP_FILE_HEADER + sdf.format(new Date()) + BACKUP_ZIP_FILE_SUB_FILE_NAME;
    this.callBack = callBack;

    this.progressDialog = new ProgressDialog(context);
    progressDialog.setMessage("Loading...");
    progressDialog.setCancelable(false);
    progressDialog.setProgressStyle(android.R.style.Widget_ProgressBar);
    progressDialog.show();
}

AsyncTask 부분이 실행되면 초기화 과정에서 progressDialog가 생성이 되고 setMessage로 인하여 Loading이 호출된다.

@Override
protected Boolean doInBackground(Void... params) {
    boolean exportSuccessful = true;
    try {
        //Load the data
        exportDataIntoBackupManager();
        //Create backup.json
        outputBackupJson();
        //Zip the json file and photo
        zipBackupFile();
        //Delete the json file
        deleteBackupJsonFile();
    } catch (Exception e) {
        Log.e(TAG, "export fail", e);
        exportSuccessful = false;
    }

    return exportSuccessful;
}

task가 실행되면 가장 먼저 exportDataIntoBackupManager가 호출된다.

private void exportDataIntoBackupManager() throws Exception {
    dbManager.openDB();

    Cursor topicCursor = dbManager.selectTopic();
    for(int i=0; i<topicCursor.getCount(); i++){
        BackupManager.BackupTopicListBean exportTopic = loadTopicDataFormDB(topicCursor);
        if(exportTopic != null){
            backupManager.addTopic(exportTopic);
            topicCursor.moveToNext();
        } else {
            throw new Exception("backup type Exception");
        }
    }
    topicCursor.close();

    dbManager.closeDB();
}

해당 메소드가 실행되면 db로부터 topic에 대한 리스트를 가져온 뒤 순회하면서 loadTopicDataFormDB을 호출한다. 해당 부분에서는 ITopic 인터페이스의 TYPE의 되는 정보를 DB로부터 가져와서 그에 맞는 구조로 파싱해서 List에 추가해준다.

private void outputBackupJson() throws IOException {
    Writer writer = new FileWriter(backupJsonFilePath);
    Gson gson = new GsonBuilder().create();
    gson.toJson(backupManager, writer);
    writer.close();
}

outputBackupJson에서 사용하는 gson은 자바 객체를 json으로 serialize해주는 라이브러리이다. 해당 라이브러리를 통해 backupManager가 가지고 있는 ArrayList값을 json으로 바꿔준다.

private void zipBackupJsonFile(String backupJsonFilePath, ZipOutputStream out) throws IOException {
    byte data[] = new byte[BUFFER_SIZE];
    FileInputStream fi = new FileInputStream(backupJsonFilePath);
    BufferedInputStream jsonFileOrigin = new BufferedInputStream(fi, BUFFER_SIZE);
    ZipEntry entry = new ZipEntry(BackupManager.BACKUP_JSON_FILE_NAME);
    out.putNextEntry(entry);
    int count;
    while ((count = jsonFileOrigin.read(data, 0, BUFFER_SIZE)) != -1) {
        out.write(data, 0, count);
    }
}

그 후 FileInputStream을 BufferedInputStream으로 만든 뒤 ZipEntry에 추가하여 write를 수행함으로써 zip파일을 생성한다.

@Override
protected void onPostExecute(Boolean exportSuccessful) {
    super.onPostExecute(exportSuccessful);
    progressDialog.dismiss();
    if(exportSuccessful){
        Toast.makeText(mContext, String.format("%1$s export successful", backupZipFileName), Toast.LENGTH_LONG).show();
        callBack.onExportCompiled(backupZipRootPath + "/" + backupZipFileName);
    } else {
        Toast.makeText(mContext, "export fail... please check permission or storage", Toast.LENGTH_LONG).show();
    }
}

작업이 마무리되면 호출되는 OnPostExecute 부분에서는 callback이 호출된다.

@Override
public void onExportCompiled(String backupZipFilePath) {
    try {
        Intent sendIntent = new Intent();
        if(backupZipFilePath != null){
            File backupFile = new File(backupZipFilePath);
            Uri backupFileUri = FileProvider.getUriForFile(this,
                    this.getApplicationContext().getPackageName()+".provider", backupFile);
            sendIntent.setAction(Intent.ACTION_SEND);
            sendIntent.putExtra(Intent.EXTRA_STREAM, backupFileUri);
            sendIntent.setType("application/zip");
            startActivity(Intent.createChooser(sendIntent, "Share with"));
        }
    } catch (Exception e){
        e.printStackTrace();
    }
}

안드로이드 컴포넌트 중 Provider는 데이터를 제공하며 응용 프로그램끼리 데이터를 공유하는 방식으로 많이 사용된다. 이 부분에서는 파일 공유를 위하여 FileProvider가 호출이 된다. 그리고 provider는 package name + .provider의 형태를 가지는데 manifest에 선언되어야 사용할 수 있다.

    <provider
        android:authorities="${applicationId}.provider"
        android:name="androidx.core.content.FileProvider"
        android:exported="false"
        android:grantUriPermissions="true">
        <meta-data
            android:name="android.support.FILE_PROVIDER_PATHS"
            android:resource="@xml/nnf_provider_paths"/>
    </provider>

이름으로 application의 패키지 이름 + provider를 가지며 exported False를 통해 다른 어플리케이션에서 해당 프로바이더를 사용하지 못하게 하며 잠시 동안 유저가 기능을 사용할 수 있도록 grantUriPermission 권한이 주어졌다.

<?xml version="1.0" encoding="utf-8"?>
<paths>
    <external-path
        name="external_files"
        path="." />
    <root-path
        name="root"
        path="." />
</paths>

그리고 paths에 대한 정보를 담고 있는 path에 대한 설정을 해주어야 된다. 하지만 위에 있는 provider의 경우 FilePickerFragment 번들 내부에 하드코딩 되어 있기 떄문에 nnf_provider_paths로부터 정보를 가져와 사용하므로 default한 프로바이더는 아니다.

ContactsActivity

해당 액티비티에서는 RecyclerView로 동작하는 여러 기능들은 위에서 살펴보았고 특별한 점이라고는 전화 intent를 사용한다는 것이다.

TelephonyManager tm = (TelephonyManager) getActivity().getSystemService(Context.TELEPHONY_SERVICE);
if (tm.getPhoneType() == TelephonyManager.PHONE_TYPE_NONE) {
    //No module for calling phone
    Toast.makeText(getActivity(), getString(R.string.contacts_call_phone_no_call_function), Toast.LENGTH_LONG)
            .show();
} else {
    //Can call phone
    Intent intent = new Intent(Intent.ACTION_CALL, Uri.parse("tel:" + contactsPhoneNumber));
    startActivity(intent);
}
dismiss();

위와 같이 TELEPHONY_SERVICE 정보를 가져와 ACTION_CALL을 발생시켜 전화를 수행하는 activity를 호출하는 것이 가능하다.

private void loadContacts() {
    contactsNamesList.clear();
    dbManager.openDB();
    Cursor contactsCursor = dbManager.selectContacts(topicId);
    for(int i=0; i<contactsCursor.getCount(); i++){
        contactsNamesList.add(
                new ContactsEntity(contactsCursor.getLong(0),
                        contactsCursor.getString(1),
                        contactsCursor.getString(2),
                        contactsCursor.getString(3))
        );
        contactsCursor.moveToNext();
    }
    contactsCursor.close();
    dbManager.closeDB();

    sortContacts();
}
@Override
public void addContacts() {
    loadContacts();
    contactsAdapter.notifyDataSetChanged();
}

adapter에 알리는 부분은 배열을 초기화하고 내용을 다시 db로부터 가져온 뒤, notifyDataSetChanged을 하는 방식으로 진행되었다.

MemoActivity

ItemTouchHelper라는 서브클래스가 등장하는데 viewholder의 drag와 같은 이벤트를 처리하기 위해 사용된다.

@Override
public boolean onTouch(View v, MotionEvent event) {
    if (MotionEventCompat.getActionMasked(event) == MotionEvent.ACTION_DOWN) {
        dragStartListener.onStartDrag(this);
    }
    return false;
}

보통은 adapter내부에 onTouch 와 같은 이벤트가 발생하고 원하는 행동일 경우 interface를 통해 해당 행위를 전달한다.


ItemTouchHelper.Callback callback = new MemoItemTouchHelperCallback(memoAdapter);
touchHelper = new ItemTouchHelper(callback);
touchHelper.attachToRecyclerView(RecyclerView_memo);

@Override
public void onStartDrag(RecyclerView.ViewHolder viewHolder) {
    touchHelper.startDrag(viewHolder);
}

Recycler_view에 touchHelper를 callback과 함께 연결하는데 MemoItemTouchHelperCallback은 swap또는 drag의 행동을 판별하는데 사용되며 onMove, getMovementFlags 등의 함수를 오버라이딩하여 재정의한다. 이렇게 행동에 대해 정의된 callback을 가진 ItemTouchHelper가 호출이 되면 그에 맞는 콜백이 실행되며 위치를 움직이거나 밀어서 삭제하는 것과 같은 행위가 가능해지게 된다.

private void setMemoContent(MemoViewHolder memoViewHolder, int itemPosition) {
    if (memoList.get(itemPosition).isChecked()) {
        SpannableString spannableContent = new SpannableString(memoList.get(itemPosition).getContent());
        spannableContent.setSpan(new StrikethroughSpan(), 0, spannableContent.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
        memoViewHolder.getTVContent().setText(spannableContent);
        memoViewHolder.getTVContent().setAlpha(0.4F);
    } else {
        memoViewHolder.getTVContent().setText(memoList.get(itemPosition).getContent());
        memoViewHolder.getTVContent().setAlpha(1F);
    }
}

또 다른 재밌는 코드는 SpannableString이다. 해당 부분을 통해 Text의 형태를 결정하는데 StrikethroughSpan을 사용하여 선택된 홀더의 text부분에 중간 밑줄을 그음으로써 특정 메모에 대한 상태를 나타낸다.