<file_sep>package com.example.webpagescannerapp.other;

import com.example.webpagescannerapp.service.RequestService;

public class MyRunnable implements Runnable {

    RequestService requestService;

    public MyRunnable(RequestService requestService){
        this.requestService = requestService;
    }

    @Override
    public void run() {
        requestService.launch();
    }

}
<file_sep># WebpageScannerApp
## Описание
  Мобильное приложение воспроизводит поиск на web-страницах. В зависимости от количества<br/>
страниц, введенных пользователем, осуществляется построение дерева(графа) страниц по принципу "в ширину"<br/>
Далее запускается поиск текста на страницах, при чем используя параматрезированный набор потоков.<br/>

Из входных данных от пользователя необходимо запросить стартовый url, текст для поиска, количество страниц и потоков.<br/>

### Пользовательский интерфейс
  Интерфейс взаимодействия с пользователем осуществлен на двух Activity: MainActivity, SearchActivity<br/>
  
В MainActivity пользователь вводит необходимые входные данные, валидация полей также реализована<br/>
В SearchActivity пользователь может увидить "терминал" с информацией о сканируемых адресах, статусе и тд.<br/>

Также есть панель управления, которая состоит из кнопок PauseButton, StartButton, StopButton<br/>
Над панелью управления расположен ProgressBar, который динамически прогрессирует во время просмотра страниц.<br/>

### Предположения (ответ на вопрос в ТЗ)
1. В задании указано, что надо осуществлять поиск текста и показывать статус "Найдено/Не найдено/Ошибка". Соответственно задача-минимум
это проверить наличие хотя бы одного совпадения текста на сканируемой странице. В приложении реализована чуть более сложная задача:
это поиск количества совпадений. В терминале на каждом url будет отметка Matches: N, где N - кол-во совпадений.<br/>
2. Предполагаем, что количество потоков сканирования точно больше 0. (В NumberPicker view нельзя выбрать 0 потоков). UI thread<br/>
не считаем.<br/>
3. Не указана необходимость адаптировать приложение под различный версии ОС и экраны, предположим, что достаточно концептуальной 
реализации без адаптаций.<br/>

### Кратко об архитектуре
Алгоритм реализации основной задачи (поиска информации) разделен в несколько этапов.<br/>
1. Сбор входных данных делается на MainActivity.<br/>
2. ScannerService рекурсивно сканирует ссылки начиная с базовой и строит граф ссылок и их уровня в условном дереве.<br/>
Структура данных используется - LinkedHashMap для удовлетворения последовательности нод и уникальности.<br/>
3. RequestService проходит по созданной мапе и делает GET-запросы получая информацию со страницы в обьект RequestInfo.<br/>
Всю полученную информацию параллельно отображает на терминале (RecyclerView), который виден пользователю.<br/>

## Технологии
* Java core, collections, Executor API (for multithreading)
* OkHttp3 (http client)
* Retrofit2 (rest client)
* Jsoup (html parsing library)
* Android Studio

## Тестирование
Правильный подход к тестированию заключается в изначальном составлении Testing strategy/Testing plan/Requirement specification<br/>
Для базовой бизнес логики можно составить юнит-тесты используя к примеру фреймворк Mockito<br/>
Для тестирования внутри потоков можно использовать например Thread Weaver<br/>

## Screenshots
![](screenshots/web1.png)
![](screenshots/web2.png)
<file_sep>include ':app'
rootProject.name = "WebpageScannerApp"<file_sep>package com.example.webpagescannerapp.service;

import android.app.Activity;
import android.os.Build;
import android.widget.Toast;

import androidx.annotation.RequiresApi;

import org.jsoup.Jsoup;
import org.jsoup.nodes.Document;
import org.jsoup.nodes.Element;
import org.jsoup.select.Elements;

import java.io.IOException;
import java.util.AbstractMap;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.stream.Collectors;

import okhttp3.OkHttpClient;
import okhttp3.Request;

public class ScannerService {

    // This service collects HashMap of pages that we should pass (number entered by user)
    // Recursive method that collects url's by Breadth-Search-Tree method

    private OkHttpClient client;    // http client
    private LinkedHashMap<String, Integer> map;     // Our main map with data

    private String baseUrl, currentUrl;
    private int maxLinkNumber, currentLinkNumber;
    private int currentLevel;

    public ScannerService(OkHttpClient client, String myBaseUrl, int myMaxLinksNumber){
        this.client = client;
        this.baseUrl = myBaseUrl;
        this.maxLinkNumber = myMaxLinksNumber;
        currentUrl = myBaseUrl;
        this.currentLinkNumber = 0;
        map = new LinkedHashMap<>(myMaxLinksNumber);
    }

    // Map Getter
    public LinkedHashMap<String, Integer> getMap() {
        return map;
    }

    // Document Getter
    private Document getDocument() throws IOException {
        Request request = new Request.Builder()
                .url(currentUrl)
                .get().build();

        return Jsoup.parse(client.newCall(request).execute().body().string());
    }

    // Recursive function for collecting data into map
    public void fillMap(Activity activity) throws IOException, InterruptedException {

        //Assume that maxLinkNumber >= 1

        // Lvl 0 processing
        if (currentLevel == 0){
            map.put(baseUrl, 0);
            currentLinkNumber++;
            currentLevel++;

            // If links processed number < max links then go next
            if (currentLinkNumber < maxLinkNumber){
                // Recursive call
                fillMap(activity);
            }
            else {
                return;
            }

        }   // Lvl 1 processing
        else if (currentLevel == 1){
            final Elements[] links = new Elements[1];
            final int[] fullSize = new int[1];
            Runnable r = () -> {
                try {
                    links[0] = getDocument().select("a[href^=http]"); // start with http
                    fullSize[0] = links[0].size();
                    activity.runOnUiThread(() -> Toast.makeText(activity, "fullSize :" + fullSize[0], Toast.LENGTH_SHORT)
                            .show());
                } catch (IOException e) {
                    e.printStackTrace();
                }
            };

            Thread t = new Thread(r);
            t.start();
            t.join();

            for (Element link : links[0]){
                if (currentLinkNumber < maxLinkNumber){
                    // Avoid repeated urls
                    if (!map.containsKey(link.attr("href"))){
                        map.put(link.attr("href"), currentLevel);
                        currentLinkNumber++;
                    }
                }
                else {
                    return;
                }
            }
            currentLevel++;

            // If links processed number < max links then go next
            if (currentLinkNumber < maxLinkNumber){
                // Recursive call
                fillMap(activity);
            }
            else {
                return;
            }
        }
        else {  // Process level 2+
            for (int j = 0; j < map.entrySet().stream().filter(node ->
                    node.getValue() == currentLevel-1).count(); j++){

                LinkedHashMap<String, Integer> mapOfCurrentLevel = map.entrySet().stream()
                        .filter(node -> node.getValue() == currentLevel-1)
                        .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue, (x, y) -> y, LinkedHashMap::new));

                for (Map.Entry<String, Integer> node : mapOfCurrentLevel.entrySet()){
                    // Current url assign to entry with value of first match with url of current lvl
                    currentUrl = node.getKey();

                    final Elements[] linksOfNode = new Elements[1];
                    Runnable r = () -> {
                        try {
                            linksOfNode[0] = getDocument().select("a[href^=http]"); // start with http
                        } catch (IOException e) {
                            Toast.makeText(activity, e.getMessage(), Toast.LENGTH_SHORT).show();
                        }
                    };
                    Thread t = new Thread(r);
                    t.start();
                    t.join();

                    for (Element link : linksOfNode[0]){
                        if (currentLinkNumber < maxLinkNumber){
                            // Avoid repeated urls
                            if (!map.containsKey(link.attr("href"))){
                                map.put(link.attr("href"), currentLevel);
                                currentLinkNumber++;
                            }
                        }
                        else {
                            return;
                        }
                    }
                }
                currentLevel++;

                // If links processed number < max links then go next
                if (currentLinkNumber < maxLinkNumber){
                    // Recursive call
                    fillMap(activity);
                }
                else {
                    return;
                }

            }
        }


    }

}
<file_sep>package com.example.webpagescannerapp.activity;

import androidx.annotation.RequiresApi;
import androidx.appcompat.app.AppCompatActivity;

import android.content.Intent;
import android.os.Build;
import android.os.Bundle;
import android.text.InputType;
import android.view.View;
import android.widget.EditText;
import android.widget.NumberPicker;
import android.widget.Toast;

import com.example.webpagescannerapp.databinding.ActivityMainBinding;
import com.example.webpagescannerapp.other.NumericKeyBoardTransformationMethod;
import com.example.webpagescannerapp.R;

import java.util.regex.Matcher;
import java.util.regex.Pattern;

public class MainActivity extends AppCompatActivity {

    // View binding class
    ActivityMainBinding binding;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        this.getSupportActionBar().hide();

        binding = ActivityMainBinding.inflate(getLayoutInflater());
        //setContentView(R.layout.activity_main);
        setContentView(binding.getRoot());

        setUpMaxPageNumberEditText();
        setUpNumberPicker();
    }

//    public void initViews(){
//        urlEditText = findViewById(R.id.urlEditText);
//        textEditText = findViewById(R.id.textEditText);
//        maxPageNumberEditText = findViewById(R.id.maxPageNumberEditText);
//        threadsNumberPicker = findViewById(R.id.threadsNumberPicker);
//    }

    public void setUpMaxPageNumberEditText(){
        binding.maxPageNumberEditText.setInputType(InputType.TYPE_CLASS_NUMBER | InputType.TYPE_NUMBER_VARIATION_PASSWORD);
        binding.maxPageNumberEditText.setTransformationMethod(new NumericKeyBoardTransformationMethod());
    }

    public void setUpNumberPicker(){
        binding.threadsNumberPicker.setMinValue(1);
        binding.threadsNumberPicker.setMaxValue(64);
        binding.threadsNumberPicker.setValue(1);
        binding.threadsNumberPicker.setWrapSelectorWheel(false);
        binding.threadsNumberPicker.setTextColor(getResources().getColor(R.color.colorWhite));
    }

    public void searchButtonClicked(View view) {

        if (isAllFieldsValid()){
            Intent intent = new Intent(MainActivity.this, SearchActivity.class);
            intent.putExtra("url", binding.urlEditText.getText().toString());
            intent.putExtra("text", binding.textEditText.getText().toString());
            intent.putExtra("max_pages_number", binding.maxPageNumberEditText.getText().toString());
            intent.putExtra("threads_number", binding.threadsNumberPicker.getValue());
            startActivity(intent);
        }

    }

    // Validation for Url field
    public boolean isUrlValid(){
        Pattern pattern = Pattern.compile("^(https?)://[-a-zA-Z0-9+&@#/%?=~_|!:,.;]*[-a-zA-Z0-9+&@#/%=~_|]");
        Matcher match = pattern.matcher(binding.urlEditText.getText().toString());

        if (match.matches()){
            binding.urlEditText.setBackgroundResource(R.drawable.drawable_edit_text_valid);
            return true;
        }
        else {
            binding.urlEditText.setBackgroundResource(R.drawable.drawable_edit_text_invalid);
            Toast.makeText(this, "Enter correct url please", Toast.LENGTH_SHORT).show();
            return false;
        }
    }

    // Validation for Text field
    public boolean isTextForSearchValid(){
        if (binding.textEditText.getText().toString().length() > 0){
            binding.textEditText.setBackgroundResource(R.drawable.drawable_edit_text_valid);
            return true;
        }
        else {
            binding.textEditText.setBackgroundResource(R.drawable.drawable_edit_text_invalid);
            Toast.makeText(this, "Enter correct text please", Toast.LENGTH_SHORT).show();
            return false;
        }
    }

    // Validation for Max pages field
    public boolean isMaxPagesNumberValid(){
        if (binding.maxPageNumberEditText.getText().toString().length() > 0 &&
            Integer.parseInt(binding.maxPageNumberEditText.getText().toString()) > 0){
            binding.maxPageNumberEditText.setBackgroundResource(R.drawable.drawable_edit_text_valid);
            return true;
        }
        else {
            binding.maxPageNumberEditText.setBackgroundResource(R.drawable.drawable_edit_text_invalid);
            Toast.makeText(this, "Enter correct pages-number please", Toast.LENGTH_SHORT).show();
            return false;
        }
    }

    // General validation
    private boolean isAllFieldsValid(){
        return isUrlValid() && isTextForSearchValid() && isMaxPagesNumberValid();
    }


}<file_sep>package com.example.webpagescannerapp.activity;

import android.content.Intent;
import android.os.Build;
import android.os.Bundle;
import android.view.View;
import android.widget.ImageButton;
import android.widget.ProgressBar;
import android.widget.Toast;

import androidx.annotation.Nullable;
import androidx.annotation.RequiresApi;
import androidx.appcompat.app.AppCompatActivity;
import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.RecyclerView;

import com.example.webpagescannerapp.databinding.ActivitySearchBinding;
import com.example.webpagescannerapp.other.MyRunnable;
import com.example.webpagescannerapp.R;
import com.example.webpagescannerapp.service.RequestService;
import com.example.webpagescannerapp.service.ScannerService;
import com.example.webpagescannerapp.adapter.RequestAdapter;
import com.example.webpagescannerapp.model.RequestInfo;

import java.io.IOException;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.stream.Collectors;

import okhttp3.OkHttpClient;

public class SearchActivity extends AppCompatActivity {

    ActivitySearchBinding binding;

    LinkedHashMap<String, Integer> nMap;    // Map from ScannerService

    //  Values for current iteration
    String text;
    int threadsNumber;

    ExecutorService executorService;    // Executor service for several threads execution
    List<Runnable> terminatedWorkersList;   // When process paused (For executor.shutDownNow())

    RequestAdapter requestAdapter;      // Adapter for RecyclerView
    ArrayList<RequestInfo> requestList;     // List for RecyclerView

    @Override
    protected void onCreate(@Nullable Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        this.getSupportActionBar().hide();

        binding = ActivitySearchBinding.inflate(getLayoutInflater());
//        setContentView(R.layout.activity_search);
        setContentView(binding.getRoot());

        //initViews();
        initControlPanel();

        Intent intent = getIntent();

        // Getting data from intent
        String url = intent.getStringExtra("url");
        text = intent.getStringExtra("text");
        int maxPagesNumber = Integer.parseInt(intent.getStringExtra("max_pages_number"));
        threadsNumber = intent.getIntExtra("threads_number", 1);

        // Setting http client
        OkHttpClient okHttpClient = new OkHttpClient();

        // Setting recycler view
        setUpRecyclerView();

        // Building tree of url's
        ScannerService scanner1 = new ScannerService(okHttpClient, url, maxPagesNumber);

        try{
            scanner1.fillMap(SearchActivity.this);
        } catch (IOException | InterruptedException e) {
            Toast.makeText(this, e.getMessage(), Toast.LENGTH_SHORT).show();
        }

        // Setting progress bar (horizontal)
        setUpProgressBar(scanner1);

        // Getting map from ScannerService
        nMap = scanner1.getMap();

        // Creating and launching ExecutorService
        executorService = Executors.newFixedThreadPool(threadsNumber);
        launchExecutor();
    }

//    public void initViews(){
//        progressBar = findViewById(R.id.progressBar);
//        pauseButton = findViewById(R.id.pauseButton);
//        playButton = findViewById(R.id.playButton);
//        stopButton = findViewById(R.id.stopButton);
//        recyclerView = findViewById(R.id.recyclerView);
//    }

    public void initControlPanel(){
        binding.playButton.setEnabled(false);
        binding.playButton.setAlpha(0.5f);

        binding.pauseButton.setEnabled(true);
        binding.pauseButton.setAlpha(1.0f);

        binding.stopButton.setEnabled(true);
        binding.stopButton.setAlpha(1.0f);
    }

    public void setUpRecyclerView(){
        requestList = new ArrayList<>();

        requestAdapter = new RequestAdapter(this, requestList);
        binding.recyclerView.setAdapter(requestAdapter);

        LinearLayoutManager linearLayoutManager = new LinearLayoutManager(this);
        linearLayoutManager.setReverseLayout(true);
        linearLayoutManager.setStackFromEnd(true);
        binding.recyclerView.setLayoutManager(linearLayoutManager);
    }

    public void setUpProgressBar(ScannerService scanner){
        binding.progressBar.setMin(0);
        binding.progressBar.setMax(scanner.getMap().size());
        binding.progressBar.setProgress(0);
    }

    private void switchPlayPauseButtons(boolean isPlaying){
        if (isPlaying){
            binding.playButton.setEnabled(false);
            binding.playButton.setAlpha(0.5f);
            binding.pauseButton.setEnabled(true);
            binding.pauseButton.setAlpha(1.0f);
        }
        else {
            binding.playButton.setEnabled(true);
            binding.playButton.setAlpha(1.0f);
            binding.pauseButton.setEnabled(false);
            binding.pauseButton.setAlpha(0.5f);
        }
    }

    public void playButtonClicked(View view) {
        switchPlayPauseButtons(true);
        resumeExecutor();
    }

    public void pauseButtonClicked(View view) {
        switchPlayPauseButtons(false);
        pauseExecutor();
    }

    public void stopButtonClicked(View view){
        executorService.shutdownNow();
        Toast.makeText(this, "FULL STOP", Toast.LENGTH_SHORT).show();
        binding.stopButton.setAlpha(0.5f);
        binding.stopButton.setEnabled(false);
        binding.pauseButton.setAlpha(0.5f);
        binding.pauseButton.setEnabled(false);
    }

    private void pauseExecutor(){
        terminatedWorkersList = executorService.shutdownNow();
    }

    private void resumeExecutor(){
        launchExecutor();
    }

    private void launchExecutor(){

        // Start from paused_state (if conditions are unsatisfied then start_state)
        if (terminatedWorkersList != null && terminatedWorkersList.size() > 0){

            executorService = Executors.newFixedThreadPool(threadsNumber);

            for (Runnable r : terminatedWorkersList){
                executorService.execute(r);
            }
        }

        //Start or continue executing tasks
        for (Map.Entry<String, Integer> node : nMap.entrySet()){
            String currentUrl = node.getKey();

//            Runnable worker = new MyRunnable(currentUrl, SearchActivity.this, requestAdapter,
//                    recyclerView, text, requestList, progressBar);

            RequestService requestService = new RequestService(currentUrl, SearchActivity.this,
                    requestAdapter, binding.recyclerView, text, requestList, binding.progressBar);

            MyRunnable worker = new MyRunnable(requestService);
            executorService.execute(worker);

            // Remove this node via stream() -> (to maintain resume_state)
            LinkedHashMap<String, Integer> newMap = nMap.entrySet()
                    .stream()
                    .filter(e -> !e.getKey().equals(currentUrl))
                    .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue, (x, y) -> y, LinkedHashMap::new));
            nMap = newMap;
        }

        executorService.shutdown();
    }

}
<file_sep>package com.example.webpagescannerapp.model;

public enum Status {
    STATUS_FOUND, STATUS_NOT_FOUND, STATUS_ERROR
}
